From a25aff0516928c5c665ed9a4664778ad2b6f62f2 Mon Sep 17 00:00:00 2001 From: Paperclip Codex Date: Sat, 12 Sep 2026 06:51:05 +0000 Subject: [PATCH] fix(secrets): audit low-trust user-secret omissions Record a value-free binding_not_allowed access event when an inherited user-secret declaration is omitted, and classify company-secret denials consistently. Co-Authored-By: Paperclip --- server/src/__tests__/secrets-service.test.ts | 34 +++++++++++++++++++- server/src/services/secrets.ts | 18 ++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 6ced198f87..2da7ab937c 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -1558,6 +1558,16 @@ describeEmbeddedPostgres("secretService", () => { expect(resolved.manifest).toEqual([]); expect(JSON.stringify(resolved)).not.toContain("aral-bp-invoices"); + const [denialEvent] = await svc.listAccessEvents(companyId, secret.id); + expect(denialEvent).toMatchObject({ + consumerType: "project", + consumerId: "project-1", + configPath: "env.ARAL_BP_RECHNUNGEN", + outcome: "failure", + errorCode: "binding_not_allowed", + }); + expect(JSON.stringify(denialEvent)).not.toContain("aral-bp-invoices"); + // Without the opt-in the same call still hard-fails — omission is // per-call, not a change to the default enforcement. await expect( @@ -1666,7 +1676,7 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - await svc.createUserSecretDefinition(companyId, { + const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", provider: "local_encrypted", @@ -1698,6 +1708,28 @@ describeEmbeddedPostgres("secretService", () => { expect(resolved.env).toEqual({ ROUTINE_PLAIN: "still-here" }); expect(resolved.secretKeys.has("GITHUB_TOKEN")).toBe(false); expect(JSON.stringify(resolved)).not.toContain("user-one-secret"); + + const [denialEvent] = await db + .select() + .from(secretAccessEvents) + .where(and( + eq(secretAccessEvents.companyId, companyId), + eq(secretAccessEvents.userSecretDefinitionId, definition.id), + )); + expect(denialEvent).toMatchObject({ + secretId: null, + secretScope: "user", + responsibleUserId: "user-1", + credentialOwnerUserId: "user-1", + credentialSubjectType: "user", + credentialSubjectId: "user-1", + consumerType: "routine", + consumerId: "routine-1", + configPath: "env.GITHUB_TOKEN", + outcome: "failure", + errorCode: "binding_not_allowed", + }); + expect(JSON.stringify(denialEvent)).not.toContain("user-one-secret"); }); it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 87fdbaa493..db66325b22 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -712,6 +712,7 @@ type RuntimeSecretResolution = { }; type SecretResolutionErrorCode = + | "binding_not_allowed" | "binding_missing" | "secret_deleted" | "secret_inactive" @@ -822,6 +823,7 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { if (error instanceof HttpError) { const details = asRecord(error.details); switch (details?.code) { + case "binding_not_allowed": case "binding_missing": case "secret_deleted": case "secret_inactive": @@ -1155,7 +1157,7 @@ export function secretService(db: Db | DbTransaction) { async function recordAccessEvent(input: { companyId: string; - secretId: string; + secretId: string | null; userSecretDefinitionId?: string | null; secretScope?: string | null; version: number | null; @@ -4166,6 +4168,20 @@ export function secretService(db: Db | DbTransaction) { Array.isArray(context?.allowedBindingIds) && (!declaration || !context.allowedBindingIds.includes(declaration.id)) ) { + await recordAccessEvent({ + companyId, + secretId: null, + userSecretDefinitionId: definition.id, + secretScope: "user", + version: null, + provider: definition.provider as SecretProvider, + context: context ? { ...context, responsibleUserId } : undefined, + credentialOwnerUserId: responsibleUserId, + credentialSubjectType: "user", + credentialSubjectId: responsibleUserId, + outcome: "failure", + errorCode: "binding_not_allowed", + }).catch(() => undefined); throw unprocessable( "User secret declaration is outside the active low-trust boundary", { code: "binding_not_allowed" },