mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Implement user-specific secret service routes
This commit is contained in:
1 parent
9346994b27
commit
4e8cbf13e3
10 files changed
+1561
-18
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user