diff --git a/src/svrs/perm/consts.ts b/src/svrs/perm/consts.ts index db34e59..537e5e5 100644 --- a/src/svrs/perm/consts.ts +++ b/src/svrs/perm/consts.ts @@ -3,12 +3,14 @@ export { PERM_MODULE } from '$libs/svrs/perm'; export const PERM_DIAGNOSTIC_EVENTS = { DECISION: 'perm.server.decision', DENIED: 'perm.server.denied', - INDETERMINATE: 'perm.server.indeterminate' + INDETERMINATE: 'perm.server.indeterminate', + AUDIT_FAILED: 'perm.server.audit_failed' } as const; export const PERM_LOG_MSG_DECISION = 'authorization decision'; export const PERM_LOG_MSG_DENIED = 'authorization denied'; export const PERM_LOG_MSG_INDETERMINATE = 'authorization indeterminate'; +export const PERM_LOG_MSG_AUDIT_FAILED = 'authorization audit sink failed'; export const PERM_METHOD_CHECK = 'perm.check'; export const PERM_METHOD_CAN = 'perm.can'; diff --git a/src/svrs/perm/diagnostics.ts b/src/svrs/perm/diagnostics.ts index 702f6cb..7cc011b 100644 --- a/src/svrs/perm/diagnostics.ts +++ b/src/svrs/perm/diagnostics.ts @@ -9,6 +9,7 @@ import type { PermCheckInput, PermDecision } from '$libs/perm'; import { PERM_MODULE, PERM_DIAGNOSTIC_EVENTS, + PERM_LOG_MSG_AUDIT_FAILED, PERM_LOG_MSG_DECISION, PERM_LOG_MSG_DENIED, PERM_LOG_MSG_INDETERMINATE @@ -22,6 +23,8 @@ export interface PermDiagnosticMeta { readonly action: PermCheckInput['action']; readonly resource: PermCheckInput['resource']; readonly decision: PermDecision; + /** Set on `AUDIT_FAILED`: error thrown by the host's `onDecision` sink. */ + readonly error?: unknown; } export type PermDiagnosticEvent = DiagnosticEvent< @@ -42,6 +45,10 @@ const PERM_DIAGNOSTIC_LOGS: DiagnosticCatalog = { [PERM_DIAGNOSTIC_EVENTS.INDETERMINATE]: { level: LogLevel.WARN, message: PERM_LOG_MSG_INDETERMINATE + }, + [PERM_DIAGNOSTIC_EVENTS.AUDIT_FAILED]: { + level: LogLevel.ERROR, + message: PERM_LOG_MSG_AUDIT_FAILED } }; diff --git a/src/svrs/perm/engine-permissions.ts b/src/svrs/perm/engine-permissions.ts index 57fef42..4ad4def 100644 --- a/src/svrs/perm/engine-permissions.ts +++ b/src/svrs/perm/engine-permissions.ts @@ -23,6 +23,8 @@ import type { EnginePerms, EnginePermsOptions } from './types.ts'; export function createEnginePerms(options: EnginePermsOptions): EnginePerms { const runtime = createPermRuntime(options); const diagnostics = createPermDiagnostics(options.logger); + const auditSink = options.onDecision; + const auditNow = options.clock?.now ?? ((): number => Date.now()); let disposed = false; function ensureLive(method: string): void { @@ -44,6 +46,32 @@ export function createEnginePerms(options: EnginePermsOptions): EnginePerms { } else { emitPermDiagnostic(diagnostics, PERM_DIAGNOSTIC_EVENTS.DECISION, meta); } + + if (auditSink !== undefined) { + // Forward to the host's audit store. Errors are caught so an + // audit failure cannot turn a granted permission into a denial + // (or vice versa). The diagnostic surface logs the error so the + // host can wire alerts on `permission.audit_failed`. + try { + await auditSink({ + actor: input.actor, + action: input.action, + resource: input.resource, + context: input.context, + requestId: input.requestId, + decision, + settledAt: auditNow() + }); + } catch (error) { + emitPermDiagnostic(diagnostics, PERM_DIAGNOSTIC_EVENTS.AUDIT_FAILED, { + action: input.action, + resource: input.resource, + decision, + error + }); + } + } + return decision; } diff --git a/src/svrs/perm/test/engine-permissions.test.ts b/src/svrs/perm/test/engine-permissions.test.ts index 55053ff..fe4b5b2 100644 --- a/src/svrs/perm/test/engine-permissions.test.ts +++ b/src/svrs/perm/test/engine-permissions.test.ts @@ -34,6 +34,62 @@ const actorRef = { type: 'user', id: 'u1', status: 'active' }; const publicPost = { type: 'post', id: 'p1', visibility: 'public', ownerId: 'u1' }; describe('EnginePerms', () => { + it('forwards every check decision to the optional `onDecision` audit sink', async () => { + const policies = definePolicies(schema, [ + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')) + ]); + + const audit: Array<{ + action: string; + effect: string; + settledAt: number; + }> = []; + let now = 1_000; + const Perm = createEnginePerms({ + schema, + policies, + clock: { now: () => now }, + onDecision: (entry) => { + audit.push({ + action: entry.action, + effect: entry.decision.effect, + settledAt: entry.settledAt + }); + } + }); + + await Perm.check({ actor: actorRef, action: 'post.read', resource: publicPost }); + now = 2_000; + await Perm.check({ actor: actorRef, action: 'post.read', resource: publicPost }); + + expect(audit).toHaveLength(2); + expect(audit[0].action).toBe('post.read'); + expect(audit[0].effect).toBe(PERM_EFFECT_ALLOW); + expect(audit[0].settledAt).toBe(1_000); + expect(audit[1].settledAt).toBe(2_000); + }); + + it('swallows audit-sink errors so the decision still returns', async () => { + const policies = definePolicies(schema, [ + allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')) + ]); + + const Perm = createEnginePerms({ + schema, + policies, + onDecision: () => { + throw new Error('audit store down'); + } + }); + + const decision = await Perm.check({ + actor: actorRef, + action: 'post.read', + resource: publicPost + }); + expect(decision.effect).toBe(PERM_EFFECT_ALLOW); + }); + it('fails closed when a matching deny policy is indeterminate, even if allow matches', async () => { const policies = definePolicies(schema, [ deny('post.*').id('post.deny.blocked').priority(100).when(rel('post.blocked').is(actor())), diff --git a/src/svrs/perm/types.ts b/src/svrs/perm/types.ts index 989fcda..5c77e5b 100644 --- a/src/svrs/perm/types.ts +++ b/src/svrs/perm/types.ts @@ -1,5 +1,6 @@ import type { Logger } from '$libs/logger'; import type { + PermDecision, PermSchema, PermCheckInput, PermRuntime, @@ -8,8 +9,46 @@ import type { QueryCompiler } from '$libs/perm'; +/** + * Audit record produced by the engine on every `check()` decision. + * The shape is intentionally narrow: the host writes whatever subset + * its audit store cares about. The reference SQL schema in + * `svrs/perm/sql/postgres.sql` is one such consumer + * (`permission_decision_audit` table). + */ +export interface PermDecisionAuditEntry { + readonly actor: PermCheckInput['actor']; + readonly action: string; + readonly resource: PermCheckInput['resource']; + readonly context?: Record; + readonly requestId?: string; + readonly decision: PermDecision; + /** Wall-clock millis when the decision settled. */ + readonly settledAt: number; +} + +export type PermDecisionAuditSink = (entry: PermDecisionAuditEntry) => void | Promise; + export interface EnginePermsOptions extends PermRuntimeOptions { readonly logger?: Logger; + /** + * Optional sink called once per `check()` decision. Hosts wire this + * to a database (the reference schema includes a + * `permission_decision_audit` table), an event bus, or any audit + * pipeline. Errors thrown by the sink are caught and logged through + * `diagnostics` — they never cause a decision to fail or change. + * + * The engine awaits the sink so DB writes that need to commit + * before the request continues block correctly. Hosts that prefer + * fire-and-forget should not await inside their callback. + */ + readonly onDecision?: PermDecisionAuditSink; + /** + * Clock used for `settledAt` on audit entries. Defaults to + * `Date.now`. The framework's hosts wire this from + * `core.timers.clock` for deterministic audit timestamps in tests. + */ + readonly clock?: { now: () => number }; } export interface EnginePerms extends PermRuntime {