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 <noreply@paperclip.ing>
This commit is contained in:
Paperclip Codex 2026-09-12 06:51:05 +00:00
parent 1fde51483e
commit a25aff0516
2 changed files with 50 additions and 2 deletions

View File

@ -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 () => {

View File

@ -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" },