import type { AuthorizationDecision } from "./advanced.ts"; import { safeRecord, type AuthzAuditSink } from "./audit.ts"; import type { PermissionStore } from "./store.ts"; import type { AuthzCatalog, AuthzScope } from "./types.ts"; export interface AuthzResolverOptions { catalog: AuthzCatalog; store: PermissionStore; audit?: AuthzAuditSink; /** * Throw on an unregistered permission instead of denying. Defaults to true * outside production, so typos surface during development. */ strict?: boolean; /** Record allows as well as denies. Off by default to bound write volume. */ auditAllows?: boolean; } export interface DecideInput { subject: { id?: string; [key: string]: unknown } | null | undefined; permission: string; resource?: unknown; scope?: AuthzScope; } export interface AuthzResolver { /** * Effective permissions with denied entries removed — for coarse gating such * as hiding a menu section. * * NOT authoritative. A set of strings cannot express "everything under * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is * not representable here: the set still contains `post:*` while `decide()` * correctly refuses `post:delete`. Gate individual actions with `decide()` * (or `can()` / `filterCan()`), never by matching against this set. */ permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; decide(input: DecideInput): Promise; } /** Expand roles into their granted entries, following `role:` and stopping on cycles. */ export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { const out = new Set(); const seen = new Set(); const walk = (role: string) => { if (seen.has(role)) return; seen.add(role); for (const entry of catalog.roles.get(role) ?? []) { if (entry.startsWith("role:")) walk(entry.slice(5)); else out.add(entry); } }; for (const role of roles) walk(role); return out; } /** * Exact match, root wildcard, or a namespace wildcard at any depth. * * Do NOT gate access by matching against `permissionsFor()`'s result — that set * cannot represent a narrow deny beneath a broad grant, so the composition * returns true where `decide()` refuses. Use `decide()` / `can()` instead. */ export function permissionMatches(granted: Set, permission: string): boolean { if (granted.has("*") || granted.has(permission)) return true; for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { if (granted.has(`${permission.slice(0, at)}:*`)) return true; } return false; } /** * True if any entry in the deny list covers `permission`. Denies honour the * same depth-aware wildcards as grants, so denying "post:*" blocks * post:comment:delete rather than being accepted and silently doing nothing. */ export function deniedBy(denies: readonly string[], permission: string): boolean { // A non-conforming store (e.g. denies: "post:write" instead of an array) // must not silently discard an explicit deny: new Set("post:write") would // iterate the string's characters instead of throwing, so the deny would // match nothing and fail open. Array.isArray guards the SHAPE, not just // the length, so a truthy-but-non-array denies value denies by falling // through to the caller's catch instead of matching nothing here. if (!Array.isArray(denies)) return false; return denies.length ? permissionMatches(new Set(denies), permission) : false; } function isProduction(): boolean { return (process.env.NODE_ENV ?? "development") === "production"; } export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver { const { catalog, store, audit } = options; const strict = options.strict ?? !isProduction(); /** * Single source of truth for "what does this subject hold?". Returns the * raw assignments too, because `decide` needs `denies` and `permissionsFor` * does not — do NOT duplicate this logic in either caller. */ const loadEffective = async (subjectId: string, scope?: AuthzScope) => { const assignments = await store.assignmentsFor(subjectId, scope); const granted = expandRoles(catalog, assignments.roles); for (const grant of assignments.grants) granted.add(grant); return { assignments, granted }; }; const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { const { assignments, granted } = await loadEffective(subjectId, scope); if (!assignments.denies.length) return granted; // Hoist the deny set: rebuilding it per entry makes this O(grants x denies) // allocations on a per-request path whose input size an operator controls. const denySet = new Set(assignments.denies); const effective = new Set(); for (const entry of granted) { if (!permissionMatches(denySet, entry)) effective.add(entry); } return effective; }; const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { const rawId = input.subject?.id; safeRecord(audit, { subjectId: typeof rawId === "string" && rawId !== "" ? rawId : undefined, scope: input.scope, permission: input.permission, allowed: result.allowed, reason: result.reason, policy: result.policy, at: Date.now(), }); } return result; }; /** * Run every policy bound to `permission`. Returns the denial verdict of the * first failing/missing/throwing policy, or `null` if all bound policies * passed (including "no policies bound" — an implicit allow). */ const runPolicies = async ( input: DecideInput, permission: string, ): Promise => { const { subject, resource } = input; for (const name of catalog.bindings.get(permission) ?? []) { const policy = catalog.policies.get(name); if (!policy) { console.error(`[wrnexus:authz] policy '${name}' is not registered; denying`); return { allowed: false, reason: "Policy unavailable", policy: name }; } try { const verdict = await ( policy as unknown as ( s: unknown, r: unknown, ) => AuthorizationDecision | Promise )(subject, resource); if (verdict?.allowed !== true) { return { allowed: false, reason: verdict?.reason ?? "Policy denied access", policy: verdict?.policy ?? name, }; } } catch (error) { console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); return { allowed: false, reason: "Policy error", policy: name }; } } return null; }; return { permissionsFor, async decide(input) { const { permission, scope } = input; const meta = catalog.permissions.get(permission); if (!meta) { if (strict) { throw new Error( `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + `Declare it with defineAuthz() in app/authz/.`, ); } return finish(input, { allowed: false, reason: `Permission '${permission}' is not registered`, }); } const rawId: unknown = input.subject?.id; const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined; if (rawId !== undefined && rawId !== null && subjectId === undefined) { console.error("[wrnexus:authz] subject.id must be a non-empty string; denying"); return finish(input, { allowed: false, reason: "Invalid subject" }); } if (!subjectId) { if (!meta.public) { return finish(input, { allowed: false, reason: "Authentication required" }); } const denied = await runPolicies(input, permission); return finish(input, denied ?? { allowed: true, reason: "public permission" }); } let assignments; let granted: Set; try { ({ assignments, granted } = await loadEffective(subjectId, scope)); // A store returning a non-array `denies` (e.g. a single string, or // omitting the field entirely) violates the PermissionStore contract. // Treat that exactly like assignmentsFor() itself throwing — fail // closed — rather than letting a malformed shape flow into // deniedBy(): a string denies would otherwise iterate as // CHARACTERS (new Set("post:write") is a set of letters, not the // permission), so an explicit deny would silently match nothing and // be discarded, and an omitted `denies` would throw past this // function entirely if it weren't caught here. if (!Array.isArray(assignments.denies)) { throw new TypeError( "WRN-AUTHZ-STORE: assignmentsFor() must return an array for `denies`", ); } // 1. Explicit deny wins over everything, including "*", honouring wildcards. if (deniedBy(assignments.denies, permission)) { return finish(input, { allowed: false, reason: "explicit deny" }); } // 2. Must hold the permission at all. if (!meta.public && !permissionMatches(granted, permission)) { return finish(input, { allowed: false, reason: "Missing permission" }); } } catch (error) { console.error("[wrnexus:authz] permission store failed; denying", error); return finish(input, { allowed: false, reason: "Authorization store unavailable" }); } // 3. Every bound policy must pass. const denied = await runPolicies(input, permission); return finish(input, denied ?? { allowed: true }); }, }; }