feat(authz): add pluggable authorization audit sink
This commit is contained in:
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user