fix(security): fail closed on settled audit errors

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadotta 2026-09-10 00:40:10 +00:00
parent 12a5b2ee08
commit 1b1af57fa5
3 changed files with 50 additions and 20 deletions

View File

@ -397,6 +397,18 @@ describeEmbeddedPostgres("board-key allow audit / mutation atomicity", () => {
expect(await committedAudit(keyId)).toHaveLength(0);
}, 60_000);
it("fails a successful no-mutation response when its allow audit cannot persist", async () => {
// `board_api_key_id` is a uuid column, so settlement fails before the
// successful response can be sent to the client.
const app = createApp("not-a-uuid", (_req, res) => {
res.status(204).end();
});
const response = await request(app).post("/api/companies").send({});
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: "Internal server error" });
}, 60_000);
it("refuses to commit a direct mutation when the coupled audit write fails", async () => {
const marker = `pipeline-audit-failure-${randomUUID()}`;
// `board_api_key_id` is a uuid column, so this key identity makes the

View File

@ -1,6 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { boardApiKeyAuthorizationEvents, type Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
/**
* Board-key allow dispositions must be atomic with the mutation they authorize.
@ -420,15 +419,8 @@ export async function settleBoardKeyAuditContext(
if (!succeeded) return;
if (context.mutationAttempted) return;
try {
await db.insert(boardApiKeyAuthorizationEvents).values({
...pending,
details: { ...(pending.details ?? {}), coupling: BOARD_KEY_AUDIT_COUPLINGS.noMutation },
});
} catch (err) {
logger.error(
{ err, boardApiKeyId: pending.boardApiKeyId, action: pending.action },
"Failed to persist settled board-key allow disposition",
);
}
await db.insert(boardApiKeyAuthorizationEvents).values({
...pending,
details: { ...(pending.details ?? {}), coupling: BOARD_KEY_AUDIT_COUPLINGS.noMutation },
});
}

View File

@ -883,14 +883,40 @@ export function boardKeyAuthorizationMiddleware(db: Db): RequestHandler {
}
const metadata = lookupBoardKeyRoute(req.method, req.originalUrl);
const context = createBoardKeyAuditContext();
// A staged allow disposition that never reached a domain transaction is
// resolved once the response is complete: recorded when the request
// succeeded without a rollback, dropped otherwise.
const settle = (statusCode: number) => {
void settleBoardKeyAuditContext(db, context, statusCode);
};
res.on("finish", () => settle(res.statusCode));
res.on("close", () => settle(res.writableEnded ? res.statusCode : 0));
// Hold the final response until a staged allow disposition that never
// reached a domain transaction is durable. This makes an audit outage fail
// the request instead of returning an unaudited success. Read-only allows
// are already persisted by the gate, and mutations flush inside their own
// transaction, so this only gates successful no-mutation responses.
const originalEnd = res.end.bind(res);
let ending = false;
res.end = ((...args: unknown[]) => {
if (ending) return res;
ending = true;
void (async () => {
try {
await settleBoardKeyAuditContext(db, context, res.statusCode);
originalEnd(...args as Parameters<typeof originalEnd>);
} catch (err) {
logger.error(
{ err, boardApiKeyId: req.actor.keyId, action: metadata.action },
"Board-key no-mutation authorization audit failed closed",
);
if (res.headersSent) {
res.destroy(err instanceof Error ? err : new Error(String(err)));
return;
}
res.statusCode = 500;
res.removeHeader("Content-Length");
res.setHeader("Content-Type", "application/json; charset=utf-8");
originalEnd(JSON.stringify({ error: "Internal server error" }));
}
})();
return res;
}) as typeof res.end;
res.on("close", () => {
if (!ending) void settleBoardKeyAuditContext(db, context, 0);
});
// The context stays active across the downstream handler chain, so the
// instrumented database handle can couple the disposition to its mutation.
await runWithBoardKeyAuditContext(context, async () => {