From 1fde51483ead7e1649870c4873cec6b7d851b0b1 Mon Sep 17 00:00:00 2001 From: Paperclip Codex Date: Sat, 12 Sep 2026 06:19:50 +0000 Subject: [PATCH 1/4] fix(secrets): omit disallowed inherited low-trust bindings Preserve hard failure for agent-owned bindings and add a heartbeat-level negative probe proving setup_failed before adapter dispatch. Co-Authored-By: Paperclip --- .../heartbeat-process-recovery.test.ts | 67 ++++++++++++++ .../__tests__/heartbeat-project-env.test.ts | 7 ++ server/src/__tests__/secrets-service.test.ts | 92 +++++++++++++++++++ server/src/services/heartbeat.ts | 8 ++ server/src/services/secrets.ts | 88 ++++++++++++------ 5 files changed, 235 insertions(+), 27 deletions(-) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index f77f756285..3e94962c7c 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -4284,6 +4284,73 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { mockAdapterExecute.mockClear(); }); + it("keeps a disallowed agent-owned low-trust secret as a terminal pre-dispatch setup failure", async () => { + const { companyId, agentId, runId, issueId } = + await seedQueuedIssueRunFixture(); + const secrets = secretService(db); + const secret = await secrets.create(companyId, { + name: `agent-owned-negative-probe-${randomUUID()}`, + provider: "local_encrypted", + value: "must-never-reach-the-adapter", + }); + const adapterConfig = { + env: { + REVIP5787_NEGATIVE_SETUP_PROBE: { + type: "secret_ref" as const, + secretId: secret.id, + version: "latest" as const, + }, + }, + }; + + await secrets.syncEnvBindingsForTarget( + companyId, + { targetType: "agent", targetId: agentId }, + adapterConfig.env, + ); + await db + .update(agents) + .set({ + adapterConfig, + permissions: { + trustPreset: "low_trust_review", + authorizationPolicy: { + trustPreset: "low_trust_review", + reviewPreset: { + id: "low_trust_review", + version: 1, + rawOutputDisposition: "quarantine", + }, + trustBoundary: { + mode: "low_trust_review", + companyId, + issueIds: [issueId], + allowedToolClasses: ["git.read", "tests.local"], + allowedSecretBindingIds: [], + outputPromotionTarget: { type: "issue", issueId }, + }, + }, + }, + }) + .where(eq(agents.id, agentId)); + + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + + const failedRun = await waitForRunToSettle(heartbeat, runId); + expect(failedRun).toMatchObject({ + status: "failed", + errorCode: "setup_failed", + resultJson: { + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }); + expect(failedRun?.error).toContain( + "Secret binding is outside the active low-trust boundary", + ); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + it("classifies only the installed-but-not-ready sandbox provider plugin message as a configuration gap", () => { expect( parseSandboxProviderPluginNotReadyFailureMessage( diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index b14ba118ae..f7dbba6374 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -330,6 +330,13 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(resolveEnvBindings.mock.calls[2]?.[2]).toMatchObject({ allowedBindingIds: ["binding-1"], }); + // Inherited scopes (environment/project/routine) ask resolveEnvBindings to + // omit a disallowed binding rather than throw. The agent's own + // adapterConfig.env goes through resolveAdapterConfigForRuntime instead, + // which has no such option and stays hard-fail. + expect(resolveEnvBindings.mock.calls[0]?.[3]).toEqual({ omitDisallowedBindings: true }); + expect(resolveEnvBindings.mock.calls[1]?.[3]).toEqual({ omitDisallowedBindings: true }); + expect(resolveEnvBindings.mock.calls[2]?.[3]).toEqual({ omitDisallowedBindings: true }); }); it("does not project brokered GitHub credentials across a low-trust boundary", async () => { diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index dbc6050fdf..6ced198f87 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -1517,6 +1517,60 @@ describeEmbeddedPostgres("secretService", () => { expect(resolved.manifest[0]?.bindingId).toBe(binding!.id); }); + // REVIP-5787: a low-trust reviewer with allowedSecretBindingIds: [] used to + // hard-fail setup for every project carrying a project-wide env secret, even + // though the reviewer never declared that secret itself. Inherited bindings + // (environment/project/routine) must be omitted, not fatal; only the agent's + // own adapterConfig.env binding keeps the hard failure from the test above. + it("omits an inherited project secret binding outside the low-trust boundary instead of throwing, when instructed", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `project-inherited-${randomUUID()}`, + provider: "local_encrypted", + value: "aral-bp-invoices", + }); + const env = { + ARAL_BP_RECHNUNGEN: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + PROJECT_PLAIN: "still-here", + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "project", targetId: "project-1" }, env); + const [binding] = await svc.listBindings(companyId, secret.id); + expect(binding?.id).toBeTruthy(); + + const resolved = await svc.resolveEnvBindings( + companyId, + env, + { + consumerType: "project", + consumerId: "project-1", + actorType: "agent", + actorId: "low-trust-reviewer", + // Empty allowlist, exactly like a reviewOnly class-1 agent with no + // secret bindings of its own. + allowedBindingIds: [], + }, + { omitDisallowedBindings: true }, + ); + + expect(resolved.env).toEqual({ PROJECT_PLAIN: "still-here" }); + expect(resolved.secretKeys.has("ARAL_BP_RECHNUNGEN")).toBe(false); + expect(resolved.manifest).toEqual([]); + expect(JSON.stringify(resolved)).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( + svc.resolveEnvBindings(companyId, env, { + consumerType: "project", + consumerId: "project-1", + actorType: "agent", + actorId: "low-trust-reviewer", + allowedBindingIds: [], + }), + ).rejects.toMatchObject({ status: 422, details: { code: "binding_not_allowed" } }); + }); + it("fails closed at runtime for class-3 env lease rows outside the allowlist", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -1608,6 +1662,44 @@ describeEmbeddedPostgres("secretService", () => { expect(resolved.manifest[0]?.bindingId).toBe(declaration!.id); }); + it("omits an inherited user-secret declaration outside the low-trust boundary instead of throwing, when instructed", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + const svc = secretService(db); + 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 }, + ROUTINE_PLAIN: "still-here", + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionKey: "github_token", + value: "user-one-secret", + }); + + const resolved = await svc.resolveEnvBindings( + companyId, + env, + { + consumerType: "routine", + consumerId: "routine-1", + actorType: "agent", + actorId: "low-trust-reviewer", + responsibleUserId: "user-1", + allowedBindingIds: [], + }, + { omitDisallowedBindings: true }, + ); + + 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"); + }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6456843a17..9e99f663ee 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1708,6 +1708,12 @@ export async function resolveExecutionRunAdapterConfig(input: { : {}), } : undefined, + // Inherited bindings (environment/project/routine): low-trust containment + // means omitting a disallowed value, not aborting the run. Only the + // agent's own adapterConfig.env (below) stays hard-fail — a misconfigured + // agent-owned binding is the operator's mistake to fix, not something to + // silently drop. + lowTrustAllowedBindingIds !== undefined ? { omitDisallowedBindings: true } : undefined, ) : { env: {}, secretKeys: new Set(), manifest: [] }; const { @@ -1760,6 +1766,7 @@ export async function resolveExecutionRunAdapterConfig(input: { : {}), } : undefined, + lowTrustAllowedBindingIds !== undefined ? { omitDisallowedBindings: true } : undefined, ) : { env: {}, secretKeys: new Set(), manifest: [] }; if (Object.keys(projectEnvResolution.env).length > 0) { @@ -1789,6 +1796,7 @@ export async function resolveExecutionRunAdapterConfig(input: { : {}), } : undefined, + lowTrustAllowedBindingIds !== undefined ? { omitDisallowedBindings: true } : undefined, ) : { env: {}, secretKeys: new Set(), manifest: [] }; if (Object.keys(routineEnvResolution.env).length > 0) { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index dd6fa92712..87fdbaa493 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -808,6 +808,15 @@ function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderCo return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } +// True only for the low-trust-boundary rejection raised by assertBindingContext / +// resolveUserSecretValue (context.allowedBindingIds set and the binding isn't in +// it). Distinguishing this from other resolution failures (missing binding, +// inactive secret, ...) lets a caller choose to omit just this class of binding +// instead of aborting the whole resolution. +function isBindingNotAllowedError(error: unknown): boolean { + return error instanceof HttpError && asRecord(error.details)?.code === "binding_not_allowed"; +} + function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { if (isSecretProviderClientError(error)) return "provider_error"; if (error instanceof HttpError) { @@ -5101,6 +5110,7 @@ export function secretService(db: Db | DbTransaction) { companyId: string, envValue: unknown, context?: Omit, + opts?: { omitDisallowedBindings?: boolean }, ): Promise<{ env: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const record = asRecord(envValue); if (!record) return { env: {} as Record, secretKeys: new Set(), manifest: [] }; @@ -5120,37 +5130,61 @@ export function secretService(db: Db | DbTransaction) { if (binding.type === "plain") { resolved[key] = binding.value; } else if (binding.type === "secret_ref") { - const secretResolution = await resolveSecretValueInternal( - companyId, - binding.secretId, - binding.version, - context - ? { - bindingContext: { ...context, configPath: `env.${key}` }, - accessContext: { ...context, configPath: `env.${key}` }, - } - : undefined, - ); + let secretResolution: RuntimeSecretResolution; + try { + secretResolution = await resolveSecretValueInternal( + companyId, + binding.secretId, + binding.version, + context + ? { + bindingContext: { ...context, configPath: `env.${key}` }, + accessContext: { ...context, configPath: `env.${key}` }, + } + : undefined, + ); + } catch (err) { + if (opts?.omitDisallowedBindings && isBindingNotAllowedError(err)) { + logger.warn( + { envKey: key, consumerType: context?.consumerType, consumerId: context?.consumerId }, + "omitting inherited secret binding outside the active low-trust boundary", + ); + continue; + } + throw err; + } resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } else { - 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, - ); + let secretResolution: RuntimeSecretResolution | null; + try { + 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, + ); + } catch (err) { + if (opts?.omitDisallowedBindings && isBindingNotAllowedError(err)) { + logger.warn( + { envKey: key, consumerType: context?.consumerType, consumerId: context?.consumerId }, + "omitting inherited user-secret binding outside the active low-trust boundary", + ); + continue; + } + throw err; + } if (secretResolution) { resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); From a25aff0516928c5c665ed9a4664778ad2b6f62f2 Mon Sep 17 00:00:00 2001 From: Paperclip Codex Date: Sat, 12 Sep 2026 06:51:05 +0000 Subject: [PATCH 2/4] 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" }, From c667b9d913a89322dd746a6f00ebe540ab5eb0c7 Mon Sep 17 00:00:00 2001 From: Paperclip Codex Date: Sat, 12 Sep 2026 08:01:29 +0000 Subject: [PATCH 3/4] fix(secrets): expose low-trust user denials in audit log Associate value-free low-trust denial events with the configured user-secret record so the existing audit API can retrieve them.\n\nCo-Authored-By: Paperclip --- server/src/__tests__/secrets-service.test.ts | 13 ++--- server/src/services/secrets.ts | 50 ++++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 2da7ab937c..2d6eea9cb0 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -1686,7 +1686,7 @@ describeEmbeddedPostgres("secretService", () => { ROUTINE_PLAIN: "still-here", }; await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); - await svc.createCurrentUserSecretValue(companyId, "user-1", { + const userSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", { definitionKey: "github_token", value: "user-one-secret", }); @@ -1709,15 +1709,10 @@ describeEmbeddedPostgres("secretService", () => { 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), - )); + const [denialEvent] = await svc.listAccessEvents(companyId, userSecret.id); expect(denialEvent).toMatchObject({ - secretId: null, + secretId: userSecret.id, + userSecretDefinitionId: definition.id, secretScope: "user", responsibleUserId: "user-1", credentialOwnerUserId: "user-1", diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index db66325b22..fd2f9c0626 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1157,7 +1157,7 @@ export function secretService(db: Db | DbTransaction) { async function recordAccessEvent(input: { companyId: string; - secretId: string | null; + secretId: string; userSecretDefinitionId?: string | null; secretScope?: string | null; version: number | null; @@ -4164,34 +4164,36 @@ export function secretService(db: Db | DbTransaction) { ); } } - if ( - 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" }, - ); - } const secret = await getUserSecretValue({ companyId, ownerUserId: responsibleUserId, definitionId: definition.id, }); + if ( + Array.isArray(context?.allowedBindingIds) && + (!declaration || !context.allowedBindingIds.includes(declaration.id)) + ) { + if (secret) { + await recordAccessEvent({ + companyId, + secretId: secret.id, + 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" }, + ); + } if (!secret) { if (optionalBinding) return null; throw unprocessable("User secret value is not configured", { From be70dd59637083459a16cdee58301b48787f0679 Mon Sep 17 00:00:00 2001 From: Paperclip Codex Date: Sat, 12 Sep 2026 08:26:47 +0000 Subject: [PATCH 4/4] fix(secrets): authorize before user secret lookup Use an ID-only audit lookup after the low-trust boundary rejects a declaration, and defer the full user-secret row lookup until authorization succeeds.\n\nCo-Authored-By: Paperclip --- server/src/services/secrets.ts | 37 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index fd2f9c0626..d1336727b3 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1069,6 +1069,24 @@ export function secretService(db: Db | DbTransaction) { .then((rows) => rows[0] ?? null); } + async function getUserSecretValueId(input: { + companyId: string; + ownerUserId: string; + definitionId: string; + }) { + return db + .select({ id: companySecrets.id }) + .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") { @@ -4164,19 +4182,19 @@ export function secretService(db: Db | DbTransaction) { ); } } - const secret = await getUserSecretValue({ - companyId, - ownerUserId: responsibleUserId, - definitionId: definition.id, - }); if ( Array.isArray(context?.allowedBindingIds) && (!declaration || !context.allowedBindingIds.includes(declaration.id)) ) { - if (secret) { + const deniedSecret = await getUserSecretValueId({ + companyId, + ownerUserId: responsibleUserId, + definitionId: definition.id, + }); + if (deniedSecret) { await recordAccessEvent({ companyId, - secretId: secret.id, + secretId: deniedSecret.id, userSecretDefinitionId: definition.id, secretScope: "user", version: null, @@ -4194,6 +4212,11 @@ export function secretService(db: Db | DbTransaction) { { code: "binding_not_allowed" }, ); } + const secret = await getUserSecretValue({ + companyId, + ownerUserId: responsibleUserId, + definitionId: definition.id, + }); if (!secret) { if (optionalBinding) return null; throw unprocessable("User secret value is not configured", {