/** * Roles and policies. * * Read side feeds the per-request resolver; write side backs the admin API. * Built-in rows are protected: they cannot be deleted, their slug and * built-in flag are immutable, and a built-in policy's rules are frozen — * they document what the fixed level always did. Narrow a built-in role by * attaching a policy with a `deny`, not by rewriting its tier policy. */ import { isBuiltinPolicySlug, isBuiltinRoleSlug, validatePolicyRules, Permissions, type PolicyLike, type PolicyRules, } from "@premium-cms/auth"; import type { Kysely, Updateable } from "kysely"; import { ulid } from "ulidx"; import type { Database, PolicyTable, RoleTable } from "../types.js"; function parseJsonColumn(raw: string | null): T | null { if (!raw) return null; try { return JSON.parse(raw) as T; } catch { return null; } } export interface RoleRecord { id: string; slug: string; name: string; description: string | null; level: number; builtin: boolean; policies: string[]; // slugs, in sort order userCount: number; createdAt: string; updatedAt: string; } export interface PolicyRecord { id: string; slug: string; name: string; description: string | null; builtin: boolean; rules: PolicyRules; roles: string[]; // slugs of roles holding it createdAt: string; updatedAt: string; } export interface RoleInput { slug: string; name: string; description?: string | null; level?: number; policies?: string[]; // slugs } export interface PolicyInput { slug: string; name: string; description?: string | null; rules: PolicyRules; } export class AuthzError extends Error { constructor( public code: | "NOT_FOUND" | "SLUG_TAKEN" | "BUILTIN_IMMUTABLE" | "ROLE_IN_USE" | "POLICY_IN_USE" | "INVALID_RULES" | "UNKNOWN_POLICY" | "INVALID_SLUG" | "INVALID_LEVEL", message: string, public details?: Record, ) { super(message); this.name = "AuthzError"; } } const SLUG_RE = /^[a-z][a-z0-9-]{1,63}$/; const VALID_LEVELS = new Set([10, 20, 30, 40, 50]); const KNOWN_PERMISSIONS: ReadonlySet = new Set(Object.keys(Permissions)); function assertSlug(slug: string): void { if (!SLUG_RE.test(slug)) { throw new AuthzError( "INVALID_SLUG", "Slug must be 2–64 characters of lowercase letters, digits and hyphens, starting with a letter", ); } } function parseRules(raw: string): PolicyRules { return parseJsonColumn(raw) ?? {}; } export class AuthzRepository { constructor(private db: Kysely) {} // ── Roles ──────────────────────────────────────────────────────── async listRoles(): Promise { const roles = await this.db .selectFrom("_emdash_roles") .selectAll() .orderBy("level", "desc") .orderBy("name") .execute(); if (roles.length === 0) return []; const links = await this.db .selectFrom("_emdash_role_policies") .innerJoin("_emdash_policies", "_emdash_policies.id", "_emdash_role_policies.policy_id") .select([ "_emdash_role_policies.role_id", "_emdash_policies.slug", "_emdash_role_policies.sort_order", ]) .orderBy("_emdash_role_policies.sort_order") .execute(); const counts = await this.db .selectFrom("users") .select(["role_id", (eb) => eb.fn.count("id").as("n")]) .where("role_id", "is not", null) .groupBy("role_id") .execute(); const policiesByRole = new Map(); for (const l of links) { const arr = policiesByRole.get(l.role_id) ?? []; arr.push(l.slug); policiesByRole.set(l.role_id, arr); } const countByRole = new Map(counts.map((c) => [c.role_id as string, Number(c.n)])); return roles.map((r) => ({ id: r.id, slug: r.slug, name: r.name, description: r.description, level: r.level, builtin: r.builtin === 1, policies: policiesByRole.get(r.id) ?? [], userCount: countByRole.get(r.id) ?? 0, createdAt: r.created_at, updatedAt: r.updated_at, })); } async getRole(idOrSlug: string): Promise { const all = await this.listRoles(); return all.find((r) => r.id === idOrSlug || r.slug === idOrSlug) ?? null; } async createRole(input: RoleInput): Promise { assertSlug(input.slug); if (isBuiltinRoleSlug(input.slug)) { throw new AuthzError("SLUG_TAKEN", `"${input.slug}" is a built-in role`); } const level = input.level ?? 10; if (!VALID_LEVELS.has(level)) throw new AuthzError("INVALID_LEVEL", "Level must be 10, 20, 30, 40 or 50"); const existing = await this.db .selectFrom("_emdash_roles") .select("id") .where("slug", "=", input.slug) .executeTakeFirst(); if (existing) throw new AuthzError("SLUG_TAKEN", `A role with slug "${input.slug}" already exists`); const policyIds = await this.resolvePolicyIds(input.policies ?? []); const id = ulid(); const now = new Date().toISOString(); await this.db .insertInto("_emdash_roles") .values({ id, slug: input.slug, name: input.name, description: input.description ?? null, level, builtin: 0, created_at: now, updated_at: now, }) .execute(); await this.setRolePolicies(id, policyIds); return (await this.getRole(id))!; } async updateRole( idOrSlug: string, input: Partial> & { slug?: string }, ): Promise { const role = await this.getRole(idOrSlug); if (!role) throw new AuthzError("NOT_FOUND", "Role not found"); if (input.slug !== undefined && input.slug !== role.slug) { if (role.builtin) throw new AuthzError("BUILTIN_IMMUTABLE", "A built-in role's slug cannot change"); assertSlug(input.slug); if (isBuiltinRoleSlug(input.slug)) throw new AuthzError("SLUG_TAKEN", `"${input.slug}" is a built-in role`); const taken = await this.db .selectFrom("_emdash_roles") .select("id") .where("slug", "=", input.slug) .executeTakeFirst(); if (taken) throw new AuthzError("SLUG_TAKEN", `A role with slug "${input.slug}" already exists`); } if (input.level !== undefined) { if (role.builtin && input.level !== role.level) { throw new AuthzError("BUILTIN_IMMUTABLE", "A built-in role's level cannot change"); } if (!VALID_LEVELS.has(input.level)) throw new AuthzError("INVALID_LEVEL", "Level must be 10, 20, 30, 40 or 50"); } const updates: Updateable = { updated_at: new Date().toISOString() }; if (input.name !== undefined) updates.name = input.name; if (input.description !== undefined) updates.description = input.description; if (input.slug !== undefined) updates.slug = input.slug; if (input.level !== undefined) updates.level = input.level; await this.db.updateTable("_emdash_roles").set(updates).where("id", "=", role.id).execute(); if (input.policies !== undefined) { const ids = await this.resolvePolicyIds(input.policies); await this.setRolePolicies(role.id, ids); } // Keep the legacy level column on users in step with the role, so // code paths without resolved grants keep a sensible floor. if (input.level !== undefined && input.level !== role.level) { await this.db .updateTable("users") .set({ role: input.level }) .where("role_id", "=", role.id) .execute(); } return (await this.getRole(role.id))!; } async deleteRole(idOrSlug: string): Promise { const role = await this.getRole(idOrSlug); if (!role) throw new AuthzError("NOT_FOUND", "Role not found"); if (role.builtin) throw new AuthzError("BUILTIN_IMMUTABLE", "Built-in roles cannot be deleted"); if (role.userCount > 0) { throw new AuthzError("ROLE_IN_USE", `${role.userCount} user(s) still hold this role`, { userCount: role.userCount, }); } await this.db.deleteFrom("_emdash_role_policies").where("role_id", "=", role.id).execute(); await this.db.deleteFrom("_emdash_roles").where("id", "=", role.id).execute(); } private async setRolePolicies(roleId: string, policyIds: string[]): Promise { await this.db.deleteFrom("_emdash_role_policies").where("role_id", "=", roleId).execute(); if (policyIds.length === 0) return; await this.db .insertInto("_emdash_role_policies") .values( policyIds.map((policy_id, sort_order) => ({ role_id: roleId, policy_id, sort_order })), ) .execute(); } private async resolvePolicyIds(slugs: string[]): Promise { const unique = [...new Set(slugs)]; if (unique.length === 0) return []; const rows = await this.db .selectFrom("_emdash_policies") .select(["id", "slug"]) .where("slug", "in", unique) .execute(); const bySlug = new Map(rows.map((r) => [r.slug, r.id])); const missing = unique.filter((s) => !bySlug.has(s)); if (missing.length > 0) { throw new AuthzError("UNKNOWN_POLICY", `Unknown policy: ${missing.join(", ")}`, { missing }); } return unique.map((s) => bySlug.get(s)!); } // ── Policies ───────────────────────────────────────────────────── async listPolicies(): Promise { const policies = await this.db .selectFrom("_emdash_policies") .selectAll() .orderBy("builtin", "desc") .orderBy("name") .execute(); if (policies.length === 0) return []; const links = await this.db .selectFrom("_emdash_role_policies") .innerJoin("_emdash_roles", "_emdash_roles.id", "_emdash_role_policies.role_id") .select(["_emdash_role_policies.policy_id", "_emdash_roles.slug"]) .execute(); const rolesByPolicy = new Map(); for (const l of links) { const arr = rolesByPolicy.get(l.policy_id) ?? []; arr.push(l.slug); rolesByPolicy.set(l.policy_id, arr); } return policies.map((p) => ({ id: p.id, slug: p.slug, name: p.name, description: p.description, builtin: p.builtin === 1, rules: parseRules(p.rules), roles: rolesByPolicy.get(p.id) ?? [], createdAt: p.created_at, updatedAt: p.updated_at, })); } async getPolicy(idOrSlug: string): Promise { const all = await this.listPolicies(); return all.find((p) => p.id === idOrSlug || p.slug === idOrSlug) ?? null; } async createPolicy(input: PolicyInput): Promise { assertSlug(input.slug); if (isBuiltinPolicySlug(input.slug)) throw new AuthzError("SLUG_TAKEN", `"${input.slug}" is a built-in policy`); const validation = validatePolicyRules(input.rules, KNOWN_PERMISSIONS); if (!validation.valid) { throw new AuthzError("INVALID_RULES", validation.errors.join("; "), { errors: validation.errors, }); } const existing = await this.db .selectFrom("_emdash_policies") .select("id") .where("slug", "=", input.slug) .executeTakeFirst(); if (existing) throw new AuthzError("SLUG_TAKEN", `A policy with slug "${input.slug}" already exists`); const id = ulid(); const now = new Date().toISOString(); await this.db .insertInto("_emdash_policies") .values({ id, slug: input.slug, name: input.name, description: input.description ?? null, builtin: 0, rules: JSON.stringify(input.rules), created_at: now, updated_at: now, }) .execute(); return (await this.getPolicy(id))!; } async updatePolicy( idOrSlug: string, input: Partial> & { slug?: string }, ): Promise { const policy = await this.getPolicy(idOrSlug); if (!policy) throw new AuthzError("NOT_FOUND", "Policy not found"); if (input.slug !== undefined && input.slug !== policy.slug) { if (policy.builtin) throw new AuthzError("BUILTIN_IMMUTABLE", "A built-in policy's slug cannot change"); assertSlug(input.slug); if (isBuiltinPolicySlug(input.slug)) throw new AuthzError("SLUG_TAKEN", `"${input.slug}" is a built-in policy`); const taken = await this.db .selectFrom("_emdash_policies") .select("id") .where("slug", "=", input.slug) .executeTakeFirst(); if (taken) throw new AuthzError("SLUG_TAKEN", `A policy with slug "${input.slug}" already exists`); } if (input.rules !== undefined) { if (policy.builtin) { throw new AuthzError( "BUILTIN_IMMUTABLE", "A built-in policy's rules are fixed. Attach an extra policy to the role instead — a deny in it overrides this one.", ); } const validation = validatePolicyRules(input.rules, KNOWN_PERMISSIONS); if (!validation.valid) { throw new AuthzError("INVALID_RULES", validation.errors.join("; "), { errors: validation.errors, }); } } const updates: Updateable = { updated_at: new Date().toISOString() }; if (input.name !== undefined) updates.name = input.name; if (input.description !== undefined) updates.description = input.description; if (input.slug !== undefined) updates.slug = input.slug; if (input.rules !== undefined) updates.rules = JSON.stringify(input.rules); await this.db .updateTable("_emdash_policies") .set(updates) .where("id", "=", policy.id) .execute(); return (await this.getPolicy(policy.id))!; } async deletePolicy(idOrSlug: string): Promise { const policy = await this.getPolicy(idOrSlug); if (!policy) throw new AuthzError("NOT_FOUND", "Policy not found"); if (policy.builtin) throw new AuthzError("BUILTIN_IMMUTABLE", "Built-in policies cannot be deleted"); if (policy.roles.length > 0) { throw new AuthzError("POLICY_IN_USE", `Held by role(s): ${policy.roles.join(", ")}`, { roles: policy.roles, }); } await this.db.deleteFrom("_emdash_policies").where("id", "=", policy.id).execute(); } // ── Resolution inputs ──────────────────────────────────────────── /** The policies a role holds, in sort order — the resolver's input. */ async policiesForRole(roleId: string): Promise { const rows = await this.db .selectFrom("_emdash_role_policies") .innerJoin("_emdash_policies", "_emdash_policies.id", "_emdash_role_policies.policy_id") .select(["_emdash_policies.slug", "_emdash_policies.rules"]) .where("_emdash_role_policies.role_id", "=", roleId) .orderBy("_emdash_role_policies.sort_order") .execute(); return rows.map((r) => ({ slug: r.slug, rules: parseRules(r.rules) })); } /** Policies by slug, in the order given; unknown slugs are dropped. */ async policiesBySlugs(slugs: readonly string[]): Promise { if (slugs.length === 0) return []; const rows = await this.db .selectFrom("_emdash_policies") .select(["slug", "rules"]) .where("slug", "in", [...slugs]) .execute(); const bySlug = new Map(rows.map((r) => [r.slug, parseRules(r.rules)])); return slugs.filter((s) => bySlug.has(s)).map((s) => ({ slug: s, rules: bySlug.get(s)! })); } async roleSummary( roleId: string, ): Promise<{ id: string; slug: string; name: string; level: number; builtin: boolean } | null> { const r = await this.db .selectFrom("_emdash_roles") .select(["id", "slug", "name", "level", "builtin"]) .where("id", "=", roleId) .executeTakeFirst(); return r ? { id: r.id, slug: r.slug, name: r.name, level: r.level, builtin: r.builtin === 1 } : null; } async roleIdForSlug(slug: string): Promise { const r = await this.db .selectFrom("_emdash_roles") .select("id") .where("slug", "=", slug) .executeTakeFirst(); return r?.id ?? null; } }