Bloque K3 — perm decision audit sink

`EnginePermsOptions.onDecision?: PermDecisionAuditSink` lets hosts
forward every `check()` decision to an audit pipeline (a database, an
event bus, S3, etc.). The reference SQL schema's
`permission_decision_audit` table is one such consumer — the engine
gives the host the data, the host writes wherever its compliance
needs.

The sink is awaited so DB writes that need to commit before the
request continues block correctly. Errors thrown by the sink are
caught and emitted as the new `perm.server.audit_failed` diagnostic
(LogLevel.ERROR) — an audit failure cannot turn a granted permission
into a denial or vice versa. Hosts wire alerts on that event.

`EnginePermsOptions.clock?: { now }` controls the `settledAt`
timestamp on audit entries — defaults to `Date.now`, hosts wire
`core.timers.clock` for deterministic audit timestamps in tests.

Test covers (a) the sink receives every decision with `settledAt` from
the injected clock, (b) sink errors are swallowed and the decision
still returns the expected effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 5d6777bfa4
commit 40da4def72

@ -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';

@ -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<PermDiagnosticEvent> = {
[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
}
};

@ -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;
}

@ -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())),

@ -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<string, unknown>;
readonly requestId?: string;
readonly decision: PermDecision;
/** Wall-clock millis when the decision settled. */
readonly settledAt: number;
}
export type PermDecisionAuditSink = (entry: PermDecisionAuditEntry) => void | Promise<void>;
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 {

Loading…
Cancel
Save

Powered by TurnKey Linux.