/** * uat/cli/lib/rbac-matrix.ts — Pure RBAC projection (specific to /uat). * * Projects a route/endpoint permission requirement onto each role, producing the * ground-truth `access` map a `.plantest.yml` encodes. Wildcard-aware, matching * the SmartStack permission model (core.nav_Permissions: Path, Action, Level, * IsWildcard x core.auth_RolePermissions). PURE — no DB access; callers pass the * already-loaded grants (sql-discovery.ts does the I/O). * * Verdict rules (plan section 1.4): * - anonymous -> redirect_login (never authenticated) * - role grants a permission matching the requirement -> allowed * - otherwise -> denied */ import type { AccessVerdict } from './plantest-schema.js'; /** * A permission granted to a role (one row of core.auth_RolePermissions joined to * core.nav_Permissions). `path` is dot-segmented, e.g. "administration.users.read". * `isWildcard` marks a grant that also covers descendants of `path`. */ export interface GrantedPermission { path: string; isWildcard: boolean; } /** Reserved role name treated as never-authenticated. */ export const ANONYMOUS_ROLE = 'anonymous'; /** Split a dot-path into segments, dropping a trailing `*` and empty segments. */ function segments(path: string): string[] { return path.split('.').filter((s) => s.length > 0 && s !== '*'); } /** * Does a granted permission satisfy a required permission? * - non-wildcard grant: satisfies only an EXACT match * - wildcard grant: satisfies the requirement when the grant's segments are a * prefix of (or equal to) the requirement's segments — e.g. grant * "administration.users" [wildcard] satisfies "administration.users.read", * grant "administration.*" satisfies any "administration.<...>". */ export function permissionMatches(granted: GrantedPermission, required: string): boolean { const g = segments(granted.path); const r = segments(required); if (g.length === 0) return false; if (!granted.isWildcard) { return g.length === r.length && g.every((seg, i) => seg === r[i]); } if (g.length > r.length) return false; return g.every((seg, i) => seg === r[i]); } /** Verdict for ONE role given its grants and a required permission. */ export function accessFor( role: string, requiredPermission: string | undefined, grants: readonly GrantedPermission[], ): AccessVerdict { if (role === ANONYMOUS_ROLE) return 'redirect_login'; // No permission required -> any authenticated role may access. if (!requiredPermission) return 'allowed'; return grants.some((g) => permissionMatches(g, requiredPermission)) ? 'allowed' : 'denied'; } /** * Build the full `access` map (every role -> verdict) for a required permission. * Roles absent from `grantsByRole` are treated as having no grants (=> denied, * unless anonymous or no permission required). */ export function buildAccessMap( roles: readonly string[], requiredPermission: string | undefined, grantsByRole: Readonly>, ): Record { const out: Record = {}; for (const role of roles) { out[role] = accessFor(role, requiredPermission, grantsByRole[role] ?? []); } return out; }