// A policy is a pure function of (input, actor, row, ctx). Purity is what lets the same // object be evaluated in an HTTP request, a job, a live query and an MCP tool without // any of them re-implementing the rule — one authz system, never two. import type { Ctx } from '@ultimat3/core'; import { emptyClauseList } from './errors'; import { actorHas } from './grant-index'; import { assertPermission, type KnownPermission, type Permission } from './permissions'; import { markPreInput, type PreInputRun, preInputRun } from './pre-input-brand'; import type { Actor } from './roles'; export type PolicyDecision = | { readonly allowed: true } | { readonly allowed: false; readonly reason: string; readonly code: string }; export const ALLOWED: PolicyDecision = { allowed: true }; export const denied = (reason: string, code = 'X_FORBIDDEN'): PolicyDecision => ({ allowed: false, reason, code, }); /** * The one shape every predicate sees, on every surface. An input-level rule reads `input`, * a row-level rule reads `row`, and neither has to know which surface called it. */ export interface PolicyArgs { readonly input: I; readonly actor: Actor | null; /** * The already-loaded row a row-level rule decides about; `null` when the rule decides on * input alone. * * Required and nullable rather than optional, deliberately. An optional `row?: R` lets the * two shapes drift apart again: a surface that forgets to pass it still typechecks, so a * row rule reading `args.row` silently sees `undefined` and denies (or worse, allows) for * the wrong reason. `R | null` forces every caller to say which case it is, and makes * "no row here" a value the predicate can branch on instead of an absence it must guess at. * This is the field that used to be smuggled inside `input` by the realtime row gate. */ readonly row: R | null; readonly ctx?: Ctx; } export type PolicyPredicate = (args: PolicyArgs) => boolean | PolicyDecision; export type PolicyKind = 'permission' | 'allow' | 'deny' | 'and' | 'or' | 'not'; export interface TraceEntry { readonly label: string; readonly kind: PolicyKind; readonly depth: number; readonly allowed: boolean; readonly reason: string | null; readonly code: string | null; } export type Recorder = (entry: TraceEntry) => void; /** `R` is defaulted so `Policy` keeps meaning "decides on input, any row or none". */ export interface Policy { readonly kind: PolicyKind; /** Stable, human-readable, safe to log: shown in traces and denial reasons. */ readonly label: string; readonly permissions: readonly Permission[]; readonly children: readonly Policy[]; run(args: PolicyArgs, record?: Recorder, depth?: number): PolicyDecision; } const record = ( recorder: Recorder | undefined, policy: { kind: PolicyKind; label: string }, depth: number, decision: PolicyDecision, ): PolicyDecision => { recorder?.({ label: policy.label, kind: policy.kind, depth, allowed: decision.allowed, reason: decision.allowed ? null : decision.reason, code: decision.allowed ? null : decision.code, }); return decision; }; /** * A value that IS a decision, by its shape and nothing looser: `allowed` exactly `true`, or * exactly `false` with the reason and code a denial carries. `{ allowed: 'yes' }` is not one — * read as a decision it was truthy, and every surface took it for ALLOWED. */ export const isDecision = (value: unknown): value is PolicyDecision => { if (typeof value !== 'object' || value === null) return false; const { allowed, reason, code } = value as Readonly>; return ( allowed === true || (allowed === false && typeof reason === 'string' && typeof code === 'string') ); }; /** * `true` allows. Everything that is not `true` and not a decision DENIES: a predicate is typed * `boolean | PolicyDecision`, but a JS caller, an `as` and a forgotten `return` all hand back * something else, and passing it through made `decision.allowed` a bare `TypeError` out of `run`. */ const asDecision = (result: unknown, label: string): PolicyDecision => { if (result === true) return ALLOWED; if (isDecision(result)) return result; return denied( result === false ? `${label} predicate returned false` : `${label} predicate returned neither a boolean nor a decision`, ); }; /** * The blessed constructor. The permission is checked first and the optional * predicate second, so a denial reason distinguishes "you may never do this" from * "you may, but not to this row" — an agent can act on the difference. */ export const can = ( permission: KnownPermission, predicate?: PolicyPredicate, ): Policy => { assertPermission(permission); const label = permission; // The actor half of the rule, on its own: it reads nothing the caller sent, which is what lets // the before-input gate (`pre-input.ts`) refuse a caller who may never call this before their // payload is parsed — and a 403 then says nothing about the input schema. const gate = (actor: Actor | null): PolicyDecision | undefined => { if (actor === null) return denied(`no actor for ${label}`, 'X_UNAUTHENTICATED'); if (!actorHas(actor, permission as Permission)) return denied(`actor lacks ${label}`); return undefined; }; const policy: Policy = { kind: 'permission', label, permissions: [permission as Permission], children: [], run(args, recorder, depth = 0) { const refused = gate(args.actor); if (refused !== undefined) return record(recorder, this, depth, refused); if (predicate === undefined) return record(recorder, this, depth, ALLOWED); return record(recorder, this, depth, asDecision(predicate(args), label)); }, }; return markPreInput(policy, (args, recorder, depth) => { const refused = gate(args.actor); if (refused !== undefined) return record(recorder, policy, depth, refused); // A predicate reads the input (or the row), so with one the clause is undecided until then. return predicate === undefined ? record(recorder, policy, depth, ALLOWED) : undefined; }); }; /** Explicitly public. Saying so is required; forgetting a policy is a build error. */ export const allow = (label = 'allow'): Policy => { const policy: Policy = { kind: 'allow', label, permissions: [], children: [], run(_args, recorder, depth = 0) { return record(recorder, this, depth, ALLOWED); }, }; return markPreInput(policy, (_args, recorder, depth) => record(recorder, policy, depth, ALLOWED)); }; export const deny = ( reason: string, code = 'X_FORBIDDEN', ): Policy => { const policy: Policy = { kind: 'deny', label: `deny(${reason})`, permissions: [], children: [], run(_args, recorder, depth = 0) { return record(recorder, this, depth, denied(reason, code)); }, }; return markPreInput(policy, (_args, recorder, depth) => record(recorder, policy, depth, denied(reason, code)), ); }; /** * Every permission a policy tree references, deduped and sorted. This is the list a compliance * report has to read: `label` renders a composite as `and(post:publish, org:administer)`, which * is a sentence, not a permission — matching a grant against it reports every non-trivial rule's * permissions as unenforced. * * A `not()` clause contributes its inner permissions too. The grant still decides the outcome, * so "nothing references this permission" would be the false statement, not this one. */ export const policyPermissions = ( policy: Policy, ): readonly Permission[] => policy.permissions; /** * The capability a policy names, for manifests, OpenAPI metadata and the `/_x` dashboard — a * DISPLAY label. What a report MATCHES on is `policyPermissions`; this is the sentence beside it. */ export const policyCapability = (policy: Policy): string => policy.label; const flatten = (children: readonly { readonly permissions: readonly Permission[] }[]) => [...new Set(children.flatMap((child) => child.permissions))].sort(); // Combinators hand `args` to every child untouched — `row` included. A clause that rewrote // the args would be the second authz shape all over again. const combined = ( kind: PolicyKind, label: string, children: readonly Policy[], decide: (args: PolicyArgs, recorder: Recorder | undefined, depth: number) => PolicyDecision, pre: PreInputRun, ): Policy => { const policy: Policy = { kind, label, permissions: flatten(children), children, run(args, recorder, depth = 0) { return record(recorder, this, depth, decide(args, recorder, depth + 1)); }, }; return markPreInput(policy, (args, recorder, depth) => { const decision = pre(args, recorder, depth + 1); return decision === undefined ? undefined : record(recorder, policy, depth, decision); }); }; /** * First denial wins, and its reason is the reason — short-circuit, left to right. * * An EMPTY clause list is refused here rather than answered: the loop below finds nothing to deny * and returns ALLOWED, so `and(...requiredCaps.map(can))` over a list that filtered to nothing is * a policy that admits an anonymous caller on every surface. `allow('public')` is how that is said * on purpose. The rest parameter stays a rest parameter — a required first argument would make the * spread that produces this bug a type error AND make every legitimate spread one too. */ export const and = (...policies: readonly Policy[]): Policy => { if (policies.length === 0) throw emptyClauseList('and'); return combined( 'and', `and(${policies.map((policy) => policy.label).join(', ')})`, policies, (args, recorder, depth) => { for (const policy of policies) { const decision = policy.run(args, recorder, depth); if (!decision.allowed) return decision; } return ALLOWED; }, // Any clause that denies whatever the input is denies the conjunction whatever the input is — // even past an undecided clause to its left, whose own verdict could only add a denial. (args, recorder, depth) => { let undecided = false; for (const policy of policies) { const decision = preInputRun(policy, args, recorder, depth); if (decision === undefined) undecided = true; else if (!decision.allowed) return decision; } return undecided ? undefined : ALLOWED; }, ); }; /** * First allowance wins; if none allow, the LAST denial is reported — **except** that a denial * carrying `X_UNAUTHENTICATED` outranks one that does not. * * That exception is what makes `not()`'s rule below true of a TREE rather than only of a direct * `can()` child. `not(or(can('order:internal'), deny('read-only mode')))` allowed `actor: null`: * the `can` clause raised `X_UNAUTHENTICATED`, `deny`'s `X_FORBIDDEN` overwrote it as the last * denial, and `not()` had nothing left to recognise, so it inverted a decision that was made * because there was no actor. It is also the more accurate code on its own terms — a clause that * could not be decided without an actor makes "authenticate and retry" a real instruction, which * a 403 is not. * * `and` needs no such rule: it short-circuits, so the denial it reports IS the deciding one and * every clause after it went unevaluated. * * An empty clause list is refused for the reason `and`'s is — see `emptyClauseList`. */ export const or = (...policies: readonly Policy[]): Policy => { if (policies.length === 0) throw emptyClauseList('or'); return combined( 'or', `or(${policies.map((policy) => policy.label).join(', ')})`, policies, (args, recorder, depth) => { let last: PolicyDecision = denied('no clause allowed this actor'); let unauthenticated: PolicyDecision | undefined; for (const policy of policies) { const decision = policy.run(args, recorder, depth); if (decision.allowed) return ALLOWED; if (decision.code === 'X_UNAUTHENTICATED') unauthenticated = decision; last = decision; } return unauthenticated ?? last; }, // Decided only when some clause allows, or every clause denies; one undecided clause could // still allow, so the disjunction waits for the input. (args, recorder, depth) => { let last: PolicyDecision = denied('no clause allowed this actor'); let unauthenticated: PolicyDecision | undefined; let undecided = false; for (const policy of policies) { const decision = preInputRun(policy, args, recorder, depth); if (decision === undefined) { undecided = true; continue; } if (decision.allowed) return ALLOWED; if (decision.code === 'X_UNAUTHENTICATED') unauthenticated = decision; last = decision; } if (undecided) return undefined; return unauthenticated ?? last; }, ); }; /** * Inverts a decision about an actor's grants — and only that. * * `X_UNAUTHENTICATED` is propagated, never inverted. "There is no actor" is not a fact about * this actor's permissions, so flipping it makes `not(can('order:internal'))` read as *allow * everyone who is not internal, anonymous callers first*. That is how a "public unless internal" * route becomes a public internal route: the mistake is invisible in `and(can(…), not(can(…)))` * because the first clause carries the authentication, and it ships the moment someone simplifies. */ export const not = (policy: Policy): Policy => { const invert = (decision: PolicyDecision): PolicyDecision => { if (decision.allowed) return denied(`not(${policy.label}) — inner clause allowed`); if (decision.code === 'X_UNAUTHENTICATED') return decision; return ALLOWED; }; return combined( 'not', `not(${policy.label})`, [policy], (args, recorder, depth) => invert(policy.run(args, recorder, depth)), (args, recorder, depth) => { const decision = preInputRun(policy, args, recorder, depth); return decision === undefined ? undefined : invert(decision); }, ); };