Implement user-specific secret service routes

This commit is contained in:
Dotta committed 2026-07-01 16:54:31 +00:00
1 parent 9346994b27
commit 4e8cbf13e3
10 files changed
+1561 -18

No files matched your search

+14
View File
@@ -174,11 +174,21 @@ up separately when you need full instance disaster recovery.
Paperclip stores secret metadata and versions in:
- `user_secret_definitions`
- `user_secret_declarations`
- `company_secrets`
- `company_secret_versions`
- `company_secret_bindings`
- `secret_access_events`
Company secrets use `company_secrets.scope = 'company'` and are bound directly
through `company_secret_bindings`. User-specific secrets reuse the same provider
and version storage, but each value is a `company_secrets.scope = 'user'` row
with `owner_user_id` and `user_secret_definition_id` set. Definitions describe
the reusable company-level slot, declarations record where `user_secret_ref`
bindings are required, and the concrete value is selected later for the
responsible user.
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
For local/default installs, the active provider is `local_encrypted`:
@@ -188,6 +198,10 @@ For local/default installs, the active provider is `local_encrypted`:
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
- User-scoped values use the same local encrypted provider path. Database
backups preserve definitions, declarations, owner metadata, version metadata,
and access events, but restored user-scoped values are decryptable only when
the matching local master key is restored with the database.
Optional overrides:
+41 -4
View File
@@ -4,7 +4,7 @@ Operational contract for the hosted `aws_secrets_manager` secret provider used b
## Scope
- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
- Hosted provider for Paperclip-managed company and user-scoped secrets when Paperclip Cloud runs on AWS.
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
@@ -120,6 +120,25 @@ Tag set for Paperclip-managed secrets:
- `paperclip:secret-key=<secret key>`
- `paperclip:environment=<environment tag>`
When the user-secret service creates Paperclip-managed AWS values, keep them
under the same deployment/company namespace. The secret-key segment should be
non-sensitive and collision-safe for the user-secret value, normally derived
from the definition key plus an opaque credential-owner subject. Do not put
emails, personal names, OAuth scopes, ticket identifiers, or plaintext
credential material in AWS secret names, descriptions, or tags.
For operator-owned external references, prefer a separate approved prefix such
as:
```text
paperclip-ext/<environment>/<company-id>/user-secrets/<definition-key>/<opaque-owner-id>
```
Paperclip stores the external ref and provider version as metadata. Treat those
fields as secret-adjacent: they must be redacted from comments, activity logs,
run transcripts, issue documents, and broad board views unless a route is
explicitly designed to show sanitized provider metadata.
## IAM And KMS Assumptions
Launch posture:
@@ -223,6 +242,14 @@ If selected external secrets use customer-managed KMS keys, also grant
permissions scoped to `paperclip/<deployment-id>/*`; do not broaden them for
remote import.
The same rule applies to user-scoped external refs. Paperclip derives the
responsible user and credential owner before resolution, but AWS IAM still
decides whether the runtime role can read the selected ARN/path. If the runtime
role can read a broad external prefix, that access is broader than Paperclip's
metadata policy. Use dedicated provider vaults, accounts, Regions, prefixes,
KMS keys, or runtime roles when provider-side isolation must match a
user-secret boundary.
Safe scoping guidance:
- Prefer one Paperclip runtime role per environment/account.
@@ -288,16 +315,24 @@ Guidance:
What must survive:
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
- User-secret definitions, declarations, `company_secrets.scope = 'user'`
value rows, owner user ids, responsible-user snapshots, access-event
metadata, and provider version refs.
- AWS Secrets Manager namespace under the configured deployment prefix.
- Any operator-owned external AWS prefixes linked by user-scoped external refs.
- The configured KMS key and its decrypt permissions.
Restore checklist:
1. Restore Paperclip database metadata.
2. Confirm the same AWS Secrets Manager namespace still exists.
3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
5. Run the live smoke below or a targeted runtime secret resolution test.
3. Confirm any linked user-scoped external prefixes still exist.
4. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored
managed prefix and approved external prefixes.
5. Confirm the role still has decrypt access to the CMKs referenced by
`PAPERCLIP_SECRETS_AWS_KMS_KEY_ID` and by any external user-secret values.
6. Run the live smoke below or a targeted runtime secret resolution test for
both a company secret and a user-secret value.
## Provider Outage Runbook
@@ -342,6 +377,8 @@ Response steps:
1. Stop or pause affected Paperclip runs.
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
For user-scoped incidents, include `credentialOwnerUserId`,
`responsibleUserId`, and `userSecretDefinitionId` in the review.
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
Region, deployment prefix, and approved external prefixes.
+24
View File
@@ -399,6 +399,30 @@ end at injection: the agent process can read, log, or forward the value, so
treat any secret bound to an agent as exposed to that agent. See the custody
boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries).
User-specific env bindings use a definition key instead of a concrete
`secretId`. The concrete value is resolved for the run's responsible user:
```json
{
"env": {
"GITHUB_TOKEN": {
"type": "user_secret_ref",
"key": "github_api_token",
"version": "latest",
"required": true,
"allowMissingOverride": false
}
}
}
```
`required` defaults to `true` and `allowMissingOverride` defaults to `false`.
Missing required user-secret values must fail closed before adapter dispatch.
Optional missing values omit the environment variable; they must not inject an
empty string or another user's value. Paperclip records value-free access
events with `secretScope`, `responsibleUserId`, `credentialOwnerUserId`, and
`userSecretDefinitionId`.
## Portability
Company export/import APIs represent agent and project environment requirements
+118 -5
View File
@@ -51,6 +51,114 @@ Project env applies to every issue run in that project. When a project env key
matches an agent env key, the project value wins before Paperclip injects its
own `PAPERCLIP_*` runtime variables.
## User-Specific Secrets
User-specific secrets let a shared agent or project declare a slot such as
`github_api_token`, then resolve the value owned by the run's responsible user
at dispatch time. The environment binding stores only the definition key:
```json
{
"env": {
"GITHUB_TOKEN": {
"type": "user_secret_ref",
"key": "github_api_token",
"required": true,
"allowMissingOverride": false
}
}
}
```
Paperclip stores the feature as value-free metadata plus the user's own secret
value record:
- `user_secret_definitions`: company-level metadata for the reusable slot.
- `user_secret_declarations`: target/config-path declarations for
`user_secret_ref` bindings, including required/optional policy.
- `company_secrets` rows with `scope = "user"`, `owner_user_id`, and
`user_secret_definition_id`: the current user's actual value record.
- `company_secret_versions`: encrypted or provider-backed version metadata for
the value record.
Board/admin operators manage definitions and coverage. Individual users manage
their own values. Board/admin coverage views must stay metadata-only: they can
show missing, configured, inactive, provider, and vault status, but not
plaintext values, raw external refs, provider credentials, or provider error
payloads.
Required user-secret refs fail closed when the responsible user is missing,
the definition is missing, or the responsible user has no active value.
Optional refs may omit the env var. They must not inject blank credentials or
fall back to another user's value.
### Vault Placement
User-scoped values can use the same provider families as company secrets:
- `local_encrypted`: the default local path. It is appropriate for local
trusted installs and small self-hosted deployments. The same master key
protects both company and user-scoped values.
- `aws_secrets_manager`: the hosted/provider-vault path. Use it when the
deployment already relies on AWS Secrets Manager, KMS, CloudTrail, and
infrastructure IAM for custody.
- Dedicated provider vaults: optional. Use a dedicated vault only when you need
a separate AWS account, Region, KMS key, prefix, retention posture, or
import boundary for user-owned values. Do not create one vault per user by
default; prefer one vault per environment or compliance boundary.
A user-secret definition can carry provider and vault defaults. A user's value
may also carry provider/vault metadata when the implementation path supports
overrides. In both cases, provider vault config contains non-secret routing
metadata only. Provider credentials still come from deployment infrastructure
identity, not from Paperclip secrets.
### External Reference Naming
External vault references are secret-adjacent metadata. Treat paths, ARNs,
versions, aliases, and tags as operationally sensitive even though they are not
plaintext secret values.
Recommended external ref shape for operator-owned AWS paths:
```text
paperclip-ext/{environment}/{company-id}/user-secrets/{definition-key}/{opaque-owner-id}
```
Guidance:
- Use the user-secret definition key for the credential type, such as
`github_api_token`.
- Use an opaque stable user id or one-way mapped subject id for the owner
segment. Avoid emails, personal names, customer names, OAuth scopes, or
ticket identifiers in provider paths.
- Keep provider metadata value-free. Safe metadata examples are provider id,
vault id, Region, KMS key id or alias, tag counts, and fingerprint hashes.
Do not store raw AWS descriptions, full tag maps, token scopes, provider
error bodies, or anything copied from the secret value.
- Keep Paperclip-managed AWS values under the Paperclip managed namespace.
External refs under that namespace are blocked by guardrails; use the
Paperclip-managed flow when Paperclip should create and rotate the value.
### IAM Caveats
Paperclip enforces company scoping, responsible-user derivation, declaration
policy, current-user value APIs, redaction, and access-event metadata. It does
not replace the IAM policy of an external vault.
For AWS Secrets Manager, the Paperclip runtime role needs `GetSecretValue` and
any required KMS decrypt permission for every user-scoped value it may resolve.
If you link user-specific external refs outside the Paperclip managed prefix,
scope those permissions to the approved external prefixes and KMS keys. AWS
tag/name filters help operators search, but they are not a reliable permission
boundary for resolution.
If stronger provider-side isolation is required, split user-secret workloads by
provider vault, AWS account, Region, prefix, or runtime role before linking the
refs. Paperclip can prevent an agent from choosing another credential owner,
but a runtime role with broad external vault read permissions can still read
what IAM allows if another code path is introduced outside Paperclip.
## Default Provider: `local_encrypted`
Secrets are encrypted with a local master key stored at:
@@ -319,13 +427,18 @@ Each provider family has a different backup story:
- `local_encrypted`: back up the local master key file and the Paperclip
database together. Either alone is not enough to restore the encrypted
values, and the vault row only records the path and acknowledgement, not the
key bytes.
key bytes. This includes user-scoped values: the database holds
`user_secret_definitions`, `user_secret_declarations`,
`company_secrets.scope = "user"` rows, version metadata, and owner ids; the
key file is required to decrypt their local material.
- `aws_secrets_manager`: back up Paperclip's database for vault metadata
(vault id, region, prefix, KMS key id, default flag, bindings, version
pointers). The actual secret values live in AWS Secrets Manager under the
configured prefix; restore by pointing the same Paperclip company at the
same AWS namespace and confirming the runtime role still has
`GetSecretValue` plus KMS decrypt. The full restore checklist lives in
pointers, user-secret definitions/declarations, owner ids, and access-event
metadata). The actual secret values live in AWS Secrets Manager under the
configured prefix or operator-owned external refs; restore by pointing the
same Paperclip company at the same AWS namespace and confirming the runtime
role still has `GetSecretValue` plus KMS decrypt for both managed and linked
user-scoped values. The full restore checklist lives in
`doc/SECRETS-AWS-PROVIDER.md`.
- `gcp_secret_manager` and `vault`: while these are coming soon, only the
draft vault config exists in Paperclip. Database backups capture it. There
@@ -19,8 +19,19 @@ const mockSecretService = vi.hoisted(() => ({
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
rotate: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
listUserSecretDefinitions: vi.fn(),
createUserSecretDefinition: vi.fn(),
updateUserSecretDefinition: vi.fn(),
removeUserSecretDefinition: vi.fn(),
getUserSecretDefinitionCoverage: vi.fn(),
listCurrentUserSecretValues: vi.fn(),
createCurrentUserSecretValue: vi.fn(),
updateCurrentUserSecretValue: vi.fn(),
rotateCurrentUserSecretValue: vi.fn(),
removeCurrentUserSecretValue: vi.fn(),
previewRemoteImport: vi.fn(),
importRemoteSecrets: vi.fn(),
}));
@@ -95,6 +106,73 @@ describe("secret routes", () => {
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("restricts user secret definition management to company admins", async () => {
const res = await request(createApp({
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
})).post("/api/companies/company-1/user-secret-definitions").send({
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
expect(res.status).toBe(403);
expect(mockSecretService.createUserSecretDefinition).not.toHaveBeenCalled();
});
it("creates current-user secret values for the authenticated user only", async () => {
mockSecretService.createCurrentUserSecretValue.mockResolvedValue({
id: "secret-1",
companyId: "company-1",
scope: "user",
ownerUserId: "user-1",
userSecretDefinitionId: "definition-1",
provider: "local_encrypted",
latestVersion: 1,
});
const res = await request(createApp()).post("/api/companies/company-1/me/user-secrets").send({
definitionKey: "github_token",
value: "secret-value",
});
expect(res.status).toBe(201);
expect(mockSecretService.createCurrentUserSecretValue).toHaveBeenCalledWith(
"company-1",
"user-1",
{
definitionKey: "github_token",
definitionId: undefined,
value: "secret-value",
externalRef: undefined,
providerVersionRef: undefined,
providerConfigId: undefined,
},
{ userId: "user-1", agentId: null },
);
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("secret-value");
});
it("hides user-scoped secrets from legacy company secret mutation routes", async () => {
mockSecretService.getById.mockResolvedValue({
id: "secret-1",
companyId: "company-1",
scope: "user",
ownerUserId: "user-2",
status: "active",
});
const res = await request(createApp()).post("/api/secrets/secret-1/rotate").send({
value: "new-secret-value",
});
expect(res.status).toBe(404);
expect(mockSecretService.rotate).not.toHaveBeenCalled();
});
it("rejects provider vault routes for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
@@ -14,6 +14,8 @@ import {
companySecrets,
createDb,
secretAccessEvents,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
@@ -47,9 +49,11 @@ describeEmbeddedPostgres("secretService", () => {
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(secretAccessEvents);
await db.delete(userSecretDeclarations);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(userSecretDefinitions);
await db.delete(companySecretProviderConfigs);
await db.delete(companyMemberships);
await db.delete(agents);
@@ -387,6 +391,80 @@ describeEmbeddedPostgres("secretService", () => {
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
});
it("resolves user secret refs through responsible-user values and records owner metadata", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
await seedCompanyMember(companyId, "user-2", "member");
const svc = secretService(db);
const definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
const env = {
GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionKey: "github_token",
value: "user-one-secret",
});
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
responsibleUserId: "user-2",
}),
).rejects.toThrow(/not configured/i);
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
responsibleUserId: "user-1",
});
expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
expect(resolved.manifest[0]).toMatchObject({
configPath: "env.GITHUB_TOKEN",
envKey: "GITHUB_TOKEN",
secretId: userOneSecret.id,
secretKey: userOneSecret.key,
outcome: "success",
});
expect((await svc.list(companyId)).map((secret) => secret.id)).not.toContain(userOneSecret.id);
await expect(
svc.resolveSecretValue(companyId, userOneSecret.id, "latest", {
consumerType: "agent",
consumerId: "agent-1",
configPath: "env.GITHUB_TOKEN",
}),
).rejects.toThrow(/User-scoped secrets/i);
const events = await db
.select()
.from(secretAccessEvents)
.where(eq(secretAccessEvents.secretId, userOneSecret.id));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
companyId,
secretId: userOneSecret.id,
userSecretDefinitionId: definition.id,
secretScope: "user",
responsibleUserId: "user-1",
credentialOwnerUserId: "user-1",
credentialSubjectType: "user",
credentialSubjectId: "user-1",
outcome: "success",
});
expect(JSON.stringify(events)).not.toContain("user-one-secret");
});
it("records stable redacted failure codes for routine env secret resolution", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
+324
View File
@@ -3,17 +3,42 @@ import type { Db } from "@paperclipai/db";
import {
createSecretProviderConfigSchema,
createSecretSchema,
createUserSecretDefinitionSchema,
createUserSecretValueSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
secretProviderConfigDiscoveryPreviewSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
updateUserSecretDefinitionSchema,
updateUserSecretValueSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { forbidden } from "../errors.js";
function assertSecretDefinitionAdmin(req: Parameters<typeof assertBoard>[0], companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
if (membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole))) {
return;
}
throw forbidden("Company admin access required");
}
function currentUserId(req: Parameters<typeof assertBoard>[0]) {
assertBoard(req);
return req.actor.userId ?? "board";
}
function isCompanyScopedSecret(secret: { scope?: string | null }) {
return (secret.scope ?? "company") === "company";
}
export function secretRoutes(db: Db) {
const router = Router();
@@ -269,6 +294,285 @@ export function secretRoutes(db: Db) {
res.json(secrets);
});
router.get("/companies/:companyId/user-secret-definitions", async (req, res) => {
const companyId = req.params.companyId as string;
assertSecretDefinitionAdmin(req, companyId);
res.json(await svc.listUserSecretDefinitions(companyId));
});
router.post(
"/companies/:companyId/user-secret-definitions",
validate(createUserSecretDefinitionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertSecretDefinitionAdmin(req, companyId);
const created = await svc.createUserSecretDefinition(
companyId,
{
key: req.body.key,
name: req.body.name,
description: req.body.description,
status: req.body.status,
provider: req.body.provider ?? defaultProvider,
providerConfigId: req.body.providerConfigId,
managedMode: req.body.managedMode,
providerMetadata: req.body.providerMetadata,
usageGuidance: req.body.usageGuidance,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "user_secret_definition.created",
entityType: "user_secret_definition",
entityId: created.id,
details: { key: created.key, provider: created.provider },
});
res.status(201).json(created);
},
);
router.patch(
"/companies/:companyId/user-secret-definitions/:definitionId",
validate(updateUserSecretDefinitionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const definitionId = req.params.definitionId as string;
assertSecretDefinitionAdmin(req, companyId);
const updated = await svc.updateUserSecretDefinition(
companyId,
definitionId,
{
key: req.body.key,
name: req.body.name,
description: req.body.description,
status: req.body.status,
providerConfigId: req.body.providerConfigId,
providerMetadata: req.body.providerMetadata,
usageGuidance: req.body.usageGuidance,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
if (!updated) {
res.status(404).json({ error: "User secret definition not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "user_secret_definition.updated",
entityType: "user_secret_definition",
entityId: updated.id,
details: { key: updated.key, status: updated.status },
});
res.json(updated);
},
);
router.delete("/companies/:companyId/user-secret-definitions/:definitionId", async (req, res) => {
const companyId = req.params.companyId as string;
const definitionId = req.params.definitionId as string;
assertSecretDefinitionAdmin(req, companyId);
const removed = await svc.removeUserSecretDefinition(
companyId,
definitionId,
{ userId: req.actor.userId ?? "board", agentId: null },
);
if (!removed) {
res.status(404).json({ error: "User secret definition not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "user_secret_definition.deleted",
entityType: "user_secret_definition",
entityId: removed.id,
details: { key: removed.key },
});
res.json({ ok: true });
});
router.get("/companies/:companyId/user-secret-definitions/:definitionId/coverage", async (req, res) => {
const companyId = req.params.companyId as string;
const definitionId = req.params.definitionId as string;
assertSecretDefinitionAdmin(req, companyId);
res.json(await svc.getUserSecretDefinitionCoverage(companyId, definitionId));
});
router.get("/companies/:companyId/me/user-secrets", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listCurrentUserSecretValues(companyId, currentUserId(req)));
});
router.post(
"/companies/:companyId/me/user-secrets",
validate(createUserSecretValueSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const ownerUserId = currentUserId(req);
const created = await svc.createCurrentUserSecretValue(
companyId,
ownerUserId,
{
definitionKey: req.body.definitionKey,
definitionId: req.body.definitionId,
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: ownerUserId, agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: ownerUserId,
action: "user_secret_value.created",
entityType: "secret",
entityId: created.id,
details: {
userSecretDefinitionId: created.userSecretDefinitionId,
ownerUserId: created.ownerUserId,
provider: created.provider,
},
});
res.status(201).json(created);
},
);
router.patch(
"/companies/:companyId/me/user-secrets/:secretId",
validate(updateUserSecretValueSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const secretId = req.params.secretId as string;
assertCompanyAccess(req, companyId);
const ownerUserId = currentUserId(req);
const updated = await svc.updateCurrentUserSecretValue(
companyId,
ownerUserId,
secretId,
{
status: req.body.status,
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: ownerUserId, agentId: null },
);
if (!updated) {
res.status(404).json({ error: "User secret value not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: ownerUserId,
action: "user_secret_value.updated",
entityType: "secret",
entityId: updated.id,
details: {
userSecretDefinitionId: updated.userSecretDefinitionId,
ownerUserId: updated.ownerUserId,
status: updated.status,
},
});
res.json(updated);
},
);
router.post(
"/companies/:companyId/me/user-secrets/:secretId/rotate",
validate(updateUserSecretValueSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const secretId = req.params.secretId as string;
assertCompanyAccess(req, companyId);
const ownerUserId = currentUserId(req);
const rotated = await svc.rotateCurrentUserSecretValue(
companyId,
ownerUserId,
secretId,
{
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: ownerUserId, agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: ownerUserId,
action: "user_secret_value.rotated",
entityType: "secret",
entityId: rotated.id,
details: {
userSecretDefinitionId: rotated.userSecretDefinitionId,
ownerUserId: rotated.ownerUserId,
version: rotated.latestVersion,
},
});
res.json(rotated);
},
);
router.delete("/companies/:companyId/me/user-secrets/:secretId", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const secretId = req.params.secretId as string;
assertCompanyAccess(req, companyId);
const ownerUserId = currentUserId(req);
const removed = await svc.removeCurrentUserSecretValue(companyId, ownerUserId, secretId);
if (!removed) {
res.status(404).json({ error: "User secret value not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: ownerUserId,
action: "user_secret_value.deleted",
entityType: "secret",
entityId: removed.id,
details: {
userSecretDefinitionId: removed.userSecretDefinitionId,
ownerUserId: removed.ownerUserId,
},
});
res.json({ ok: true });
});
router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@@ -383,6 +687,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
if (!isCompanyScopedSecret(existing)) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
@@ -421,6 +729,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
if (!isCompanyScopedSecret(existing)) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
@@ -463,6 +775,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
if (!isCompanyScopedSecret(existing)) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
@@ -476,6 +792,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
if (!isCompanyScopedSecret(existing)) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
@@ -489,6 +809,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
if (!isCompanyScopedSecret(existing)) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await svc.remove(id);
@@ -18,6 +18,20 @@ interface AgentSecretBindingSyncService {
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
envValue: unknown,
) => Promise<unknown>;
syncUserSecretDeclarationsForTarget?: (
companyId: string,
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
refs: Array<{
definitionKey: string;
configPath: string;
envKey: string;
versionSelector?: SecretVersionSelector;
required?: boolean;
allowMissingOverride?: boolean;
label?: string | null;
}>,
options?: { replaceAll?: boolean },
) => Promise<unknown>;
}
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -67,6 +81,60 @@ function collectSecretRefs(adapterConfig: unknown): Array<{
return refs;
}
function collectUserSecretRefs(adapterConfig: unknown): Array<{
definitionKey: string;
configPath: string;
envKey: string;
versionSelector?: SecretVersionSelector;
required?: boolean;
allowMissingOverride?: boolean;
}> {
const config = asRecord(adapterConfig);
if (!config) return [];
const refs: Array<{
definitionKey: string;
configPath: string;
envKey: string;
versionSelector?: SecretVersionSelector;
required?: boolean;
allowMissingOverride?: boolean;
}> = [];
const envValue = asRecord(config.env);
for (const [key, rawBinding] of Object.entries(envValue ?? {})) {
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) continue;
const binding = parsed.data;
if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue;
refs.push({
definitionKey: binding.key,
configPath: `env.${key}`,
envKey: key,
versionSelector: binding.version ?? "latest",
required: binding.required ?? true,
allowMissingOverride: binding.allowMissingOverride ?? false,
});
}
for (const [key, rawBinding] of Object.entries(config)) {
if (key === "env") continue;
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) continue;
const binding = parsed.data;
if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue;
refs.push({
definitionKey: binding.key,
configPath: key,
envKey: key,
versionSelector: binding.version ?? "latest",
required: binding.required ?? true,
allowMissingOverride: binding.allowMissingOverride ?? false,
});
}
return refs;
}
export async function syncAgentAdapterEnvBindings(input: {
secretsSvc: AgentSecretBindingSyncService;
companyId: string;
@@ -80,6 +148,12 @@ export async function syncAgentAdapterEnvBindings(input: {
collectSecretRefs(input.adapterConfig),
{ replaceAll: true },
);
await input.secretsSvc.syncUserSecretDeclarationsForTarget?.(
input.companyId,
{ targetType: "agent", targetId: input.agentId },
collectUserSecretRefs(input.adapterConfig),
{ replaceAll: true },
);
return;
}
const envValue = asRecord(asRecord(input.adapterConfig)?.env);
+5
View File
@@ -512,6 +512,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
adapterType?: string | null;
issueId?: string | null;
heartbeatRunId?: string | null;
responsibleUserId?: string | null;
environmentId?: string | null;
environmentEnv?: unknown;
projectId?: string | null;
@@ -671,6 +672,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.environmentId,
actorType: "agent",
actorId: input.agentId ?? null,
responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -687,6 +689,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.agentId,
actorType: "agent",
actorId: input.agentId,
responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -713,6 +716,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.projectId,
actorType: "agent",
actorId: input.agentId ?? null,
responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -739,6 +743,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.routineId,
actorType: "agent",
actorId: input.agentId ?? null,
responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
+805 -9
View File
@@ -7,12 +7,15 @@ import {
companySecretProviderConfigs,
companySecrets,
companySecretVersions,
companyMemberships,
environments,
heartbeatRuns,
issues,
projects,
routines,
secretAccessEvents,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import type {
AgentEnvConfig,
@@ -229,6 +232,7 @@ type SecretConsumerContext = {
consumerType: SecretBindingTargetType;
consumerId: string;
configPath?: string | null;
responsibleUserId?: string | null;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant";
@@ -241,6 +245,7 @@ type SecretConsumerContext = {
type SecretResolutionOptions = {
bindingContext?: SecretConsumerContext;
accessContext?: SecretConsumerContext;
allowUserSecretScope?: boolean;
};
export type RuntimeSecretManifestEntry = {
@@ -274,6 +279,10 @@ type SecretResolutionErrorCode =
| "binding_missing"
| "secret_deleted"
| "secret_inactive"
| "secret_scope_invalid"
| "responsible_user_missing"
| "user_secret_definition_missing"
| "user_secret_missing"
| "version_missing"
| "version_inactive"
| "provider_error";
@@ -326,10 +335,6 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
};
}
function userSecretResolverUnavailable(key: string): never {
throw unprocessable(`User secret ref is declared but runtime resolution is not enabled yet: ${key}`);
}
function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus {
return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready";
}
@@ -348,6 +353,14 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
return details.code;
}
if (error.message === "Secret is not active") return "secret_inactive";
if (error.message === "User secret value is not configured") return "user_secret_missing";
if (error.message === "Responsible user is required for user secret resolution") {
return "responsible_user_missing";
}
if (error.message === "User secret definition not found") return "user_secret_definition_missing";
if (error.message === "User-scoped secrets must be resolved through user secret declarations") {
return "secret_scope_invalid";
}
if (error.message === "Secret version not found") return "version_missing";
if (error.message === "Secret version is not active") return "version_inactive";
if (
@@ -403,12 +416,92 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "company"),
eq(companySecrets.name, name),
ne(companySecrets.status, "deleted"),
))
.then((rows) => rows[0] ?? null);
}
async function getUserSecretDefinitionById(
companyId: string,
definitionId: string,
source: Pick<Db | DbTransaction, "select"> = db,
) {
return source
.select()
.from(userSecretDefinitions)
.where(and(
eq(userSecretDefinitions.companyId, companyId),
eq(userSecretDefinitions.id, definitionId),
))
.then((rows) => rows[0] ?? null);
}
async function getUserSecretDefinitionByKey(
companyId: string,
key: string,
source: Pick<Db | DbTransaction, "select"> = db,
) {
return source
.select()
.from(userSecretDefinitions)
.where(and(
eq(userSecretDefinitions.companyId, companyId),
eq(userSecretDefinitions.key, key),
ne(userSecretDefinitions.status, "deleted"),
))
.then((rows) => rows[0] ?? null);
}
async function resolveUserSecretDefinition(
companyId: string,
input: { definitionId?: string | null; definitionKey?: string | null },
source: Pick<Db | DbTransaction, "select"> = db,
) {
const definition = input.definitionId
? await getUserSecretDefinitionById(companyId, input.definitionId, source)
: input.definitionKey
? await getUserSecretDefinitionByKey(companyId, input.definitionKey, source)
: null;
if (!definition || definition.deletedAt || definition.status === "deleted") {
throw notFound("User secret definition not found");
}
if (definition.companyId !== companyId) {
throw unprocessable("User secret definition must belong to same company");
}
return definition;
}
async function getUserSecretValue(input: {
companyId: string;
ownerUserId: string;
definitionId: string;
}) {
return db
.select()
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, input.companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.ownerUserId, input.ownerUserId),
eq(companySecrets.userSecretDefinitionId, input.definitionId),
ne(companySecrets.status, "deleted"),
))
.then((rows) => rows[0] ?? null);
}
async function getUserSecretValueById(companyId: string, ownerUserId: string, secretId: string) {
const secret = await getById(secretId);
if (!secret || secret.status === "deleted" || secret.scope !== "user") {
throw notFound("User secret value not found");
}
if (secret.companyId !== companyId || secret.ownerUserId !== ownerUserId) {
throw notFound("User secret value not found");
}
return secret;
}
async function getSecretVersion(secretId: string, version: number) {
return db
.select()
@@ -481,9 +574,14 @@ export function secretService(db: Db) {
async function recordAccessEvent(input: {
companyId: string;
secretId: string;
userSecretDefinitionId?: string | null;
secretScope?: string | null;
version: number | null;
provider: SecretProvider;
context: SecretConsumerContext | undefined;
credentialOwnerUserId?: string | null;
credentialSubjectType?: string | null;
credentialSubjectId?: string | null;
outcome: "success" | "failure";
errorCode?: string | null;
}) {
@@ -491,8 +589,14 @@ export function secretService(db: Db) {
await db.insert(secretAccessEvents).values({
companyId: input.companyId,
secretId: input.secretId,
userSecretDefinitionId: input.userSecretDefinitionId ?? null,
secretScope: input.secretScope ?? "company",
version: input.version,
provider: input.provider,
responsibleUserId: input.context.responsibleUserId ?? null,
credentialOwnerUserId: input.credentialOwnerUserId ?? null,
credentialSubjectType: input.credentialSubjectType ?? null,
credentialSubjectId: input.credentialSubjectId ?? null,
actorType: input.context.actorType ?? "system",
actorId: input.context.actorId ?? null,
consumerType: input.context.consumerType,
@@ -515,6 +619,7 @@ export function secretService(db: Db) {
if (!secret) throw notFound("Secret not found");
if (secret.status === "deleted") throw notFound("Secret not found");
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
if (secret.scope !== "company") throw unprocessable("Secret references require company-scoped secrets");
return secret;
}
@@ -658,6 +763,11 @@ export function secretService(db: Db) {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
if (secret.scope !== "company" && !options?.allowUserSecretScope) {
throw unprocessable("User-scoped secrets must be resolved through user secret declarations", {
code: "secret_scope_invalid",
});
}
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
const providerId = secret.provider as SecretProvider;
const configPath = accessContext?.configPath ?? null;
@@ -701,9 +811,14 @@ export function secretService(db: Db) {
recordAccessEvent({
companyId,
secretId: secret.id,
userSecretDefinitionId: secret.userSecretDefinitionId ?? null,
secretScope: secret.scope,
version: resolvedVersion,
provider: providerId,
context: accessContext,
credentialOwnerUserId: secret.ownerUserId ?? null,
credentialSubjectType: secret.scope === "user" ? "user" : null,
credentialSubjectId: secret.ownerUserId ?? null,
outcome: "success",
}).catch(() => undefined),
]);
@@ -726,9 +841,14 @@ export function secretService(db: Db) {
await recordAccessEvent({
companyId,
secretId: secret.id,
userSecretDefinitionId: secret.userSecretDefinitionId ?? null,
secretScope: secret.scope,
version: resolvedVersion,
provider: providerId,
context: accessContext,
credentialOwnerUserId: secret.ownerUserId ?? null,
credentialSubjectType: secret.scope === "user" ? "user" : null,
credentialSubjectId: secret.ownerUserId ?? null,
outcome: "failure",
errorCode,
}).catch(() => undefined);
@@ -954,6 +1074,7 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "company"),
eq(companySecrets.key, key),
ne(companySecrets.status, "deleted"),
))
@@ -1315,6 +1436,148 @@ export function secretService(db: Db) {
return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) };
}
async function createUserSecretValueInternal(
companyId: string,
ownerUserId: string,
input: {
definitionId?: string | null;
definitionKey?: string | null;
value?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
providerConfigId?: string | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) {
const definition = await resolveUserSecretDefinition(companyId, input);
if (definition.status !== "active") {
throw unprocessable("User secret definition is not active");
}
const existing = await getUserSecretValue({
companyId,
ownerUserId,
definitionId: definition.id,
});
if (existing) throw conflict("User secret value already exists");
const providerId = definition.provider as SecretProvider;
const managedMode = definition.managedMode as "paperclip_managed" | "external_reference";
if (managedMode === "external_reference" && !input.externalRef?.trim()) {
throw unprocessable("External reference user secrets require externalRef");
}
if (managedMode === "paperclip_managed" && input.externalRef?.trim()) {
throw unprocessable("Managed user secrets cannot override externalRef");
}
if (managedMode === "paperclip_managed" && !input.value?.trim()) {
throw unprocessable("Managed user secrets require value");
}
const providerConfigId =
input.providerConfigId === undefined ? definition.providerConfigId : input.providerConfigId;
const provider = getSecretProvider(providerId);
const providerConfig = await getSelectableRuntimeProviderConfig({
companyId,
provider: providerId,
providerConfigId,
});
const idSuffix = randomUUID();
const key = normalizeSecretKey(`user.${definition.key}.${idSuffix}`);
const name = `${definition.name} (${ownerUserId})`;
const providerWriteContext = {
companyId,
secretKey: key,
secretName: definition.name,
version: 1,
};
const reservedSecret = await db
.insert(companySecrets)
.values({
companyId,
scope: "user",
ownerUserId,
userSecretDefinitionId: definition.id,
key,
name,
provider: providerId,
providerConfigId: providerConfigId ?? null,
status: "archived",
managedMode,
externalRef: null,
providerMetadata: definition.providerMetadata ?? null,
latestVersion: 0,
description: definition.description ?? null,
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
})
.returning()
.then((rows) => rows[0]);
let prepared: PreparedSecretVersion;
try {
prepared =
managedMode === "external_reference"
? await provider.linkExternalSecret({
externalRef: input.externalRef ?? "",
providerVersionRef: input.providerVersionRef ?? null,
providerConfig,
context: providerWriteContext,
})
: await provider.createSecret({
value: input.value ?? "",
externalRef: null,
providerConfig,
context: providerWriteContext,
});
} catch (error) {
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
throw error;
}
try {
return await db.transaction(async (tx) => {
await tx.insert(companySecretVersions).values({
secretId: reservedSecret.id,
version: 1,
material: prepared.material,
valueSha256: prepared.valueSha256,
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
providerVersionRef: prepared.providerVersionRef ?? null,
status: "current",
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
const secret = await tx
.update(companySecrets)
.set({
status: "active",
externalRef: prepared.externalRef,
latestVersion: 1,
lastRotatedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(companySecrets.id, reservedSecret.id))
.returning()
.then((rows) => rows[0]);
if (!secret) throw notFound("User secret value not found");
return secret;
});
} catch (error) {
if (managedMode === "paperclip_managed") {
await cleanupPreparedProviderWrite({
provider,
prepared,
providerConfig,
context: providerWriteContext,
mode: "delete",
operation: "user_secret_value.create_rollback",
}).catch(() => false);
}
await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
throw error;
}
}
return {
listProviders: () => listSecretProviders(),
@@ -1572,7 +1835,11 @@ export function secretService(db: Db) {
db
.select()
.from(companySecrets)
.where(and(eq(companySecrets.companyId, companyId), ne(companySecrets.status, "deleted")))
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "company"),
ne(companySecrets.status, "deleted"),
))
.orderBy(desc(companySecrets.createdAt)),
db
.select({
@@ -1623,6 +1890,414 @@ export function secretService(db: Db) {
.where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
.orderBy(desc(secretAccessEvents.createdAt)),
listUserSecretDefinitions: (companyId: string) =>
db
.select()
.from(userSecretDefinitions)
.where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted")))
.orderBy(desc(userSecretDefinitions.createdAt)),
getUserSecretDefinitionById: (companyId: string, definitionId: string) =>
getUserSecretDefinitionById(companyId, definitionId),
createUserSecretDefinition: async (
companyId: string,
input: {
key: string;
name: string;
description?: string | null;
status?: string;
provider: SecretProvider;
providerConfigId?: string | null;
managedMode?: "paperclip_managed" | "external_reference";
providerMetadata?: Record<string, unknown> | null;
usageGuidance?: string | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const key = input.key.trim();
const duplicate = await getUserSecretDefinitionByKey(companyId, key);
if (duplicate) throw conflict(`User secret definition already exists: ${key}`);
await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId);
return db
.insert(userSecretDefinitions)
.values({
companyId,
key,
name: input.name.trim(),
description: input.description ?? null,
status: input.status ?? "active",
provider: input.provider,
providerConfigId: input.providerConfigId ?? null,
managedMode: input.managedMode ?? "paperclip_managed",
providerMetadata: input.providerMetadata ?? null,
usageGuidance: input.usageGuidance ?? null,
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
updatedByAgentId: actor?.agentId ?? null,
updatedByUserId: actor?.userId ?? null,
})
.returning()
.then((rows) => rows[0]);
},
updateUserSecretDefinition: async (
companyId: string,
definitionId: string,
patch: {
key?: string;
name?: string;
description?: string | null;
status?: string;
providerConfigId?: string | null;
providerMetadata?: Record<string, unknown> | null;
usageGuidance?: string | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const existing = await resolveUserSecretDefinition(companyId, { definitionId });
const nextKey = patch.key?.trim() ?? existing.key;
if (nextKey !== existing.key) {
const duplicate = await getUserSecretDefinitionByKey(companyId, nextKey);
if (duplicate && duplicate.id !== existing.id) {
throw conflict(`User secret definition already exists: ${nextKey}`);
}
}
if (patch.providerConfigId !== undefined) {
await assertProviderConfigForSecret(
companyId,
existing.provider as SecretProvider,
patch.providerConfigId,
);
}
const deleting = patch.status === "deleted";
return db
.update(userSecretDefinitions)
.set({
key: deleting ? `${existing.key}__deleted__${existing.id}` : nextKey,
name: patch.name?.trim() ?? existing.name,
description: patch.description === undefined ? existing.description : patch.description,
status: patch.status ?? existing.status,
providerConfigId:
patch.providerConfigId === undefined ? existing.providerConfigId : patch.providerConfigId,
providerMetadata:
patch.providerMetadata === undefined ? existing.providerMetadata : patch.providerMetadata,
usageGuidance:
patch.usageGuidance === undefined ? existing.usageGuidance : patch.usageGuidance,
updatedByAgentId: actor?.agentId ?? null,
updatedByUserId: actor?.userId ?? null,
deletedAt: deleting ? new Date() : existing.deletedAt,
updatedAt: new Date(),
})
.where(and(
eq(userSecretDefinitions.companyId, companyId),
eq(userSecretDefinitions.id, definitionId),
))
.returning()
.then((rows) => rows[0] ?? null);
},
removeUserSecretDefinition: async (
companyId: string,
definitionId: string,
actor?: { userId?: string | null; agentId?: string | null },
) => {
const existing = await resolveUserSecretDefinition(companyId, { definitionId });
return db
.update(userSecretDefinitions)
.set({
key: `${existing.key}__deleted__${existing.id}`,
status: "deleted",
deletedAt: existing.deletedAt ?? new Date(),
updatedByAgentId: actor?.agentId ?? null,
updatedByUserId: actor?.userId ?? null,
updatedAt: new Date(),
})
.where(and(
eq(userSecretDefinitions.companyId, companyId),
eq(userSecretDefinitions.id, definitionId),
))
.returning()
.then((rows) => rows[0] ?? null);
},
getUserSecretDefinitionCoverage: async (companyId: string, definitionId: string) => {
await resolveUserSecretDefinition(companyId, { definitionId });
const [members, values] = await Promise.all([
db
.select({ principalId: companyMemberships.principalId })
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
)),
db
.select({ status: companySecrets.status, ownerUserId: companySecrets.ownerUserId })
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.userSecretDefinitionId, definitionId),
ne(companySecrets.status, "deleted"),
)),
]);
const memberIds = new Set(members.map((member) => member.principalId));
const configuredCount = values.filter((value) =>
value.status === "active" && value.ownerUserId && memberIds.has(value.ownerUserId)
).length;
const inactiveCount = values.filter((value) =>
value.status !== "active" && value.ownerUserId && memberIds.has(value.ownerUserId)
).length;
return {
definitionId,
configuredCount,
inactiveCount,
missingCount: Math.max(0, memberIds.size - configuredCount - inactiveCount),
};
},
listCurrentUserSecretValues: async (companyId: string, ownerUserId: string) => {
const definitions = await db
.select()
.from(userSecretDefinitions)
.where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted")))
.orderBy(desc(userSecretDefinitions.createdAt));
const values = await db
.select()
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.ownerUserId, ownerUserId),
ne(companySecrets.status, "deleted"),
));
const valuesByDefinitionId = new Map(values.map((value) => [value.userSecretDefinitionId, value]));
return definitions.map((definition) => ({
definition,
secret: valuesByDefinitionId.get(definition.id) ?? null,
}));
},
createCurrentUserSecretValue: createUserSecretValueInternal,
rotateCurrentUserSecretValue: async (
companyId: string,
ownerUserId: string,
secretId: string,
input: {
value?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
providerConfigId?: string | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
return await (async () => {
await resolveUserSecretDefinition(companyId, { definitionId: secret.userSecretDefinitionId });
return (await secretService(db).rotate(secret.id, input, actor));
})();
},
updateCurrentUserSecretValue: async (
companyId: string,
ownerUserId: string,
secretId: string,
patch: {
status?: "active" | "disabled" | "archived" | "deleted";
value?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
providerConfigId?: string | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
if (
patch.value !== undefined ||
patch.externalRef !== undefined ||
patch.providerVersionRef !== undefined ||
patch.providerConfigId !== undefined
) {
return await secretService(db).rotateCurrentUserSecretValue(
companyId,
ownerUserId,
secret.id,
patch,
actor,
);
}
if (patch.status === "deleted") {
return await secretService(db).removeCurrentUserSecretValue(companyId, ownerUserId, secret.id);
}
return db
.update(companySecrets)
.set({
status: patch.status ?? secret.status,
updatedAt: new Date(),
})
.where(eq(companySecrets.id, secret.id))
.returning()
.then((rows) => rows[0] ?? null);
},
removeCurrentUserSecretValue: async (companyId: string, ownerUserId: string, secretId: string) => {
const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
return await secretService(db).remove(secret.id);
},
syncUserSecretDeclarationsForTarget: async (
companyId: string,
target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
refs: Array<{
definitionKey: string;
configPath: string;
envKey: string;
versionSelector?: SecretVersionSelector;
required?: boolean;
allowMissingOverride?: boolean;
label?: string | null;
}>,
options?: { db?: SecretBindingDb; replaceAll?: boolean },
) => {
const targetDb = options?.db ?? db;
const normalizedRefs: Array<{
definitionId: string;
configPath: string;
envKey: string;
versionSelector: SecretVersionSelector;
required: boolean;
allowMissingOverride: boolean;
label: string | null;
}> = [];
for (const ref of refs) {
const definition = await resolveUserSecretDefinition(
companyId,
{ definitionKey: ref.definitionKey },
targetDb,
);
normalizedRefs.push({
definitionId: definition.id,
configPath: ref.configPath,
envKey: ref.envKey,
versionSelector: ref.versionSelector ?? "latest",
required: ref.required ?? true,
allowMissingOverride: ref.allowMissingOverride ?? false,
label: ref.label ?? null,
});
}
const pathPrefix = target.pathPrefix ?? "env";
const writeDeclarations = async (executor: SecretBindingDb) => {
if (options?.replaceAll) {
await executor
.delete(userSecretDeclarations)
.where(and(
eq(userSecretDeclarations.companyId, companyId),
eq(userSecretDeclarations.targetType, target.targetType),
eq(userSecretDeclarations.targetId, target.targetId),
));
} else {
await executor
.delete(userSecretDeclarations)
.where(and(
eq(userSecretDeclarations.companyId, companyId),
eq(userSecretDeclarations.targetType, target.targetType),
eq(userSecretDeclarations.targetId, target.targetId),
like(userSecretDeclarations.configPath, `${pathPrefix}.%`),
));
}
if (normalizedRefs.length === 0) return;
await executor.insert(userSecretDeclarations).values(
normalizedRefs.map((ref) => ({
companyId,
userSecretDefinitionId: ref.definitionId,
targetType: target.targetType,
targetId: target.targetId,
configPath: ref.configPath,
envKey: ref.envKey,
versionSelector: String(ref.versionSelector),
required: ref.required,
allowMissingOverride: ref.allowMissingOverride,
label: ref.label,
})),
);
};
if (options?.db) {
await writeDeclarations(targetDb);
} else {
await db.transaction(async (tx) => writeDeclarations(tx));
}
return normalizedRefs;
},
resolveUserSecretValue: async (
companyId: string,
input: {
definitionKey?: string | null;
definitionId?: string | null;
responsibleUserId?: string | null;
version?: SecretVersionSelector;
required?: boolean;
allowMissingOverride?: boolean;
},
context?: SecretConsumerContext,
): Promise<RuntimeSecretResolution | null> => {
const responsibleUserId = input.responsibleUserId ?? context?.responsibleUserId ?? null;
const definition = await resolveUserSecretDefinition(companyId, input);
if (definition.status !== "active") {
throw unprocessable("User secret definition is not active");
}
if (!responsibleUserId?.trim()) {
throw unprocessable("Responsible user is required for user secret resolution", {
code: "responsible_user_missing",
});
}
if (context?.configPath) {
const declaration = await db
.select()
.from(userSecretDeclarations)
.where(and(
eq(userSecretDeclarations.companyId, companyId),
eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
eq(userSecretDeclarations.targetType, context.consumerType),
eq(userSecretDeclarations.targetId, context.consumerId),
eq(userSecretDeclarations.configPath, context.configPath),
))
.then((rows) => rows[0] ?? null);
if (!declaration) {
throw unprocessable(
`User secret is not declared for ${context.consumerType}:${context.consumerId} at ${context.configPath}`,
{ code: "binding_missing" },
);
}
}
const secret = await getUserSecretValue({
companyId,
ownerUserId: responsibleUserId,
definitionId: definition.id,
});
if (!secret) {
if (input.allowMissingOverride || input.required === false) return null;
throw unprocessable("User secret value is not configured", {
code: "user_secret_missing",
definitionId: definition.id,
responsibleUserId,
});
}
return await resolveSecretValueInternal(
companyId,
secret.id,
input.version ?? "latest",
{
accessContext: context ? { ...context, responsibleUserId } : undefined,
allowUserSecretScope: true,
},
);
},
previewRemoteImport: async (
companyId: string,
input: {
@@ -1950,6 +2625,7 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "company"),
eq(companySecrets.key, key),
ne(companySecrets.status, "deleted"),
))
@@ -2274,6 +2950,7 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, secret.companyId),
eq(companySecrets.scope, "company"),
eq(companySecrets.key, nextKey),
ne(companySecrets.status, "deleted"),
))
@@ -2499,12 +3176,32 @@ export function secretService(db: Db) {
configPath: string;
versionSelector: SecretVersionSelector;
}> = [];
const userRefs: Array<{
definitionKey: string;
configPath: string;
envKey: string;
versionSelector: SecretVersionSelector;
required: boolean;
allowMissingOverride: boolean;
}> = [];
const pathPrefix = target.pathPrefix ?? "env";
const bindingDb = options?.db ?? db;
for (const [key, rawBinding] of Object.entries(record)) {
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) continue;
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "user_secret_ref") {
await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb);
userRefs.push({
definitionKey: binding.key,
configPath: `${pathPrefix}.${key}`,
envKey: key,
versionSelector: binding.version,
required: binding.required,
allowMissingOverride: binding.allowMissingOverride,
});
continue;
}
if (binding.type !== "secret_ref") continue;
await assertSecretInCompany(companyId, binding.secretId, bindingDb);
refs.push({
@@ -2536,13 +3233,49 @@ export function secretService(db: Db) {
versionSelector: String(ref.versionSelector),
required: true,
})),
);
};
const writeUserDeclarations = async (targetDb: SecretBindingDb) => {
await targetDb
.delete(userSecretDeclarations)
.where(
and(
eq(userSecretDeclarations.companyId, companyId),
eq(userSecretDeclarations.targetType, target.targetType),
eq(userSecretDeclarations.targetId, target.targetId),
like(userSecretDeclarations.configPath, `${pathPrefix}.%`),
),
);
if (userRefs.length === 0) return;
const definitions = new Map<string, string>();
for (const ref of userRefs) {
const definition = await resolveUserSecretDefinition(companyId, { definitionKey: ref.definitionKey }, targetDb);
definitions.set(ref.definitionKey, definition.id);
}
await targetDb.insert(userSecretDeclarations).values(
userRefs.map((ref) => ({
companyId,
userSecretDefinitionId: definitions.get(ref.definitionKey)!,
targetType: target.targetType,
targetId: target.targetId,
configPath: ref.configPath,
envKey: ref.envKey,
versionSelector: String(ref.versionSelector),
required: ref.required,
allowMissingOverride: ref.allowMissingOverride,
})),
);
};
if (options?.db) {
await writeBindings(options.db);
await writeUserDeclarations(options.db);
} else {
await db.transaction(async (tx) => writeBindings(tx));
await db.transaction(async (tx) => {
await writeBindings(tx);
await writeUserDeclarations(tx);
});
}
return refs;
},
@@ -2663,7 +3396,27 @@ export function secretService(db: Db) {
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
} else {
userSecretResolverUnavailable(binding.key);
const secretResolution = await secretService(db).resolveUserSecretValue(
companyId,
{
definitionKey: binding.key,
version: binding.version,
required: binding.required,
allowMissingOverride: binding.allowMissingOverride,
},
context
? {
...context,
configPath: `env.${key}`,
responsibleUserId: context.responsibleUserId ?? null,
}
: undefined,
);
if (secretResolution) {
resolved[key] = secretResolution.value;
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
}
}
}
return { env: resolved, secretKeys, manifest };
@@ -2813,7 +3566,27 @@ export function secretService(db: Db) {
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
} else {
userSecretResolverUnavailable(binding.key);
const secretResolution = await secretService(db).resolveUserSecretValue(
companyId,
{
definitionKey: binding.key,
version: binding.version,
required: binding.required,
allowMissingOverride: binding.allowMissingOverride,
},
context
? {
...context,
configPath: `env.${key}`,
responsibleUserId: context.responsibleUserId ?? null,
}
: undefined,
);
if (secretResolution) {
env[key] = secretResolution.value;
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
}
}
}
resolved.env = env;
@@ -2825,7 +3598,30 @@ export function secretService(db: Db) {
if (!parsed.success) continue;
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") continue;
if (binding.type === "user_secret_ref") userSecretResolverUnavailable(binding.key);
if (binding.type === "user_secret_ref") {
const secretResolution = await secretService(db).resolveUserSecretValue(
companyId,
{
definitionKey: binding.key,
version: binding.version,
required: binding.required,
allowMissingOverride: binding.allowMissingOverride,
},
context
? {
...context,
configPath: key,
responsibleUserId: context.responsibleUserId ?? null,
}
: undefined,
);
if (secretResolution) {
resolved[key] = secretResolution.value;
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
}
continue;
}
const secretResolution = await resolveSecretValueInternal(
companyId,
binding.secretId,