fix(security): fail closed on settled audit errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
12a5b2ee08
commit
1b1af57fa5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue