diff --git a/packages/authz/src/audit.ts b/packages/authz/src/audit.ts new file mode 100644 index 00000000..d8f6a091 --- /dev/null +++ b/packages/authz/src/audit.ts @@ -0,0 +1,56 @@ +import type { AuthzScope } from "./types.ts"; + +export interface AuthzAuditEvent { + subjectId?: string; + scope?: AuthzScope; + permission: string; + allowed: boolean; + reason?: string; + policy?: string; + /** Epoch milliseconds. */ + at: number; +} + +export interface AuthzAuditSink { + record(event: AuthzAuditEvent): void | Promise; +} + +export interface MemoryAuditSink extends AuthzAuditSink { + events: AuthzAuditEvent[]; + clear(): void; +} + +export function memoryAuditSink(): MemoryAuditSink { + const events: AuthzAuditEvent[] = []; + return { + events, + record: (event) => void events.push(event), + clear: () => void events.splice(0, events.length), + }; +} + +export function consoleAuditSink(): AuthzAuditSink { + return { + record(event) { + const verdict = event.allowed ? "allow" : "deny"; + console.info( + `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + + `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + + `${event.reason ? ` reason=${event.reason}` : ""}`, + ); + }, + }; +} + +/** Record without ever letting a sink failure escape into the request path. */ +export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void { + if (!sink) return; + try { + const result = sink.record(event); + if (result instanceof Promise) { + result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error)); + } + } catch (error) { + console.warn("[wrnexus:authz] audit sink failed", error); + } +} diff --git a/packages/authz/test/audit.test.ts b/packages/authz/test/audit.test.ts new file mode 100644 index 00000000..12b8c7d3 --- /dev/null +++ b/packages/authz/test/audit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { memoryAuditSink, safeRecord } from "../src/audit.ts"; + +describe("audit sink", () => { + test("memoryAuditSink collects events", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "post:read", allowed: true, at: 1 }); + expect(sink.events).toHaveLength(1); + expect(sink.events[0]!.permission).toBe("post:read"); + }); + + test("safeRecord swallows sink failures", () => { + const exploding = { + record() { + throw new Error("sink is down"); + }, + }; + // Auditing must never break a request. + expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + }); + + test("safeRecord swallows async sink rejections", async () => { + const rejecting = { record: async () => Promise.reject(new Error("later")) }; + expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + await Bun.sleep(1); + }); + + test("safeRecord tolerates an undefined sink", () => { + expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow(); + }); +});