/** * Per-request authorization resolution. * * Turns "this user, authenticated this way" into a decision table the rest * of the request can consult without touching the database again: * * session grants = resolve(policies of the user's role) * token grants = owner's grants ∩ resolve(token's policies) * * The role's policies are cached for a few seconds per isolate. Admin * mutations bump the cache in-process; other isolates simply age out. A * short window in which a just-edited policy is still in force on a sibling * isolate is the accepted cost of not re-reading roles on every request. */ import { builtinPolicies, builtinRoleForLevel, emptyGrants, intersectGrants, resolveGrants, type EffectiveGrants, type PolicyLike, type User, } from "@premium-cms/auth"; import type { Kysely } from "kysely"; import { AuthzRepository } from "../database/repositories/authz.js"; import type { Database } from "../database/types.js"; export interface ResolvedRole { id: string; slug: string; name: string; level: number; builtin: boolean; } export interface RequestAuthz { /** The user with `authz` attached — assign this to `locals.user`. */ user: User; role: ResolvedRole | null; /** What this request may do. For tokens, already clamped to the owner. */ grants: EffectiveGrants; /** The owner's own grants — identical to `grants` for session auth. */ ownerGrants: EffectiveGrants; /** Slugs of the policies the owner's role holds. */ rolePolicies: readonly string[]; } // ── Cache ──────────────────────────────────────────────────────────────── const CACHE_TTL_MS = 10_000; interface CachedRole { role: ResolvedRole; policies: PolicyLike[]; expires: number; } // Keyed by role id. Lives on globalThis so Vite's SSR module duplication in // dev cannot fork it; a Symbol key keeps it out of anyone else's way. const CACHE_KEY = Symbol.for("emdash.authz.roleCache"); function cache(): Map { const g = globalThis as Record; let map = g[CACHE_KEY] as Map | undefined; if (!map) { map = new Map(); g[CACHE_KEY] = map; } return map; } /** Drop cached role/policy data. Call after any role or policy mutation. */ export function invalidateAuthzCache(): void { cache().clear(); } async function loadRole(db: Kysely, roleId: string): Promise { const now = Date.now(); const hit = cache().get(roleId); if (hit && hit.expires > now) return hit; // A read failure (missing tables mid-migration, a stubbed db) must not // throw on the request path: return null so the caller synthesises the // built-in tier for the user's level in memory — never more access than // the legacy level would have granted. try { const repo = new AuthzRepository(db); const role = await repo.roleSummary(roleId); if (!role) { cache().delete(roleId); return null; } const policies = await repo.policiesForRole(roleId); const entry: CachedRole = { role, policies, expires: now + CACHE_TTL_MS }; cache().set(roleId, entry); return entry; } catch { return null; } } // ── Resolution ─────────────────────────────────────────────────────────── /** * Resolve a user's role and grants. * * Users without a role id (rows written before migration 072, or by a * code path that has not been updated) are treated as holding the built-in * role for their level, read from the database so an admin's edits to that * role apply. If even that is missing — a database mid-migration — the * built-in definition is used directly. Nothing here can produce more * access than the legacy level would have. */ /** The slice of a user the resolver needs. */ export interface RoleLinkage { role: number; roleId: string | null; } export async function resolveUserAuthz( db: Kysely, user: RoleLinkage, ): Promise<{ role: ResolvedRole | null; grants: EffectiveGrants; rolePolicies: string[] }> { const fallbackSlug = builtinRoleForLevel(user.role).slug; const roleId = user.roleId ?? `role:${fallbackSlug}`; let loaded = await loadRole(db, roleId); if (!loaded && user.roleId) { // Dangling role id: the role was removed under the user. Fall back to // the level's built-in role rather than granting nothing, which would // lock the account out of even its own profile. loaded = await loadRole(db, `role:${fallbackSlug}`); } if (loaded) { return { role: loaded.role, grants: resolveGrants(loaded.policies), rolePolicies: loaded.policies.map((p) => p.slug), }; } // No roles table content at all — synthesise the built-in tier. const builtin = builtinRoleForLevel(user.role); const policy = builtinPolicies().find((p) => p.slug === builtin.policy); const policies: PolicyLike[] = policy ? [{ slug: policy.slug, rules: policy.rules }] : []; return { role: { id: `role:${builtin.slug}`, slug: builtin.slug, name: builtin.name, level: builtin.level, builtin: true, }, grants: policies.length ? resolveGrants(policies) : emptyGrants(), rolePolicies: policies.map((p) => p.slug), }; } /** * Resolve grants for the request and attach them to the user. * * `tokenPolicies` is the slug list carried by a policy-based API token, or * null for session auth and legacy scoped tokens. */ export async function attachRequestAuthz( db: Kysely, user: User, tokenPolicies: readonly string[] | null, ): Promise { const owner = await resolveUserAuthz(db, user); let grants = owner.grants; if (tokenPolicies) { const repo = new AuthzRepository(db); const policies = await repo.policiesBySlugs(tokenPolicies); // A token whose policies no longer exist has no grants — not the // owner's. Revoking a policy must revoke the tokens built on it. grants = policies.length === 0 ? emptyGrants() : intersectGrants(owner.grants, resolveGrants(policies)); } const withAuthz: User = { ...user, authz: grants }; return { user: withAuthz, role: owner.role, grants, ownerGrants: owner.grants, rolePolicies: owner.rolePolicies, }; }