diff --git a/server/src/security/board-key-audit-atomicity.test.ts b/server/src/security/board-key-audit-atomicity.test.ts index f278e02e9d..50d9f86e3e 100644 --- a/server/src/security/board-key-audit-atomicity.test.ts +++ b/server/src/security/board-key-audit-atomicity.test.ts @@ -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 diff --git a/server/src/security/board-key-audit-coupling.ts b/server/src/security/board-key-audit-coupling.ts index 2354adb052..cbea4592c1 100644 --- a/server/src/security/board-key-audit-coupling.ts +++ b/server/src/security/board-key-audit-coupling.ts @@ -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 }, + }); } diff --git a/server/src/security/board-key-route-registry.ts b/server/src/security/board-key-route-registry.ts index b6780310d5..a58bca56c1 100644 --- a/server/src/security/board-key-route-registry.ts +++ b/server/src/security/board-key-route-registry.ts @@ -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); + } 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 () => {