/** * lib/permission-actions.ts — Canonical RBAC action vocabulary, the default * PERMISSION FLOOR per navigation grain, and the permission-path grammar. * * SINGLE SOURCE OF TRUTH mirroring two platform enums: * - `SmartStack.Domain.Authorization.PermissionAction` (12 values) * - `SmartStack.Domain.Navigation.PermissionLevel` (4 grains) * * Consumed at runtime by scaffold-core-seed (floor derivation + generated * `ParseAction`), derive-seed-delta (`ACTION_NAMES`), scaffold-controller * (`actions[]` enum), the BA CLI derive-permission-floor (rbac.md mirror * block) and the page-spec schemas. The gitflow pr gate does NOT import this * module (gitflow stays runtime-self-contained) — its contract is pinned by * the repo-side lockstep test instead. * * Markdown carriers embed the vocabulary / floor tables between versioned * markers (`permission-actions:v1`, `permission-floor:v1`); * `lib/__tests__/permission-actions-drift.test.ts` and * `lib/__tests__/permission-floor-drift.test.ts` pin them to these exports — * edit ALL carriers or the suite fails. * * ## Path grammar (multi-grain) * * | Segments | Shape | Grain | * |----------|--------------------------------------------------|---------------------| * | 2 | `{app}.{action}` | application | * | 3 | `{app}.{module}.{action}` | module | * | 4 | `{app}.{module}.{section}.{action}` | section | * | 5 | `{app}.{module}.{section}.read.all` | section (scope tier)| * | 5 | `{app}.{module}.{section}.{resource}.{action}` | resource | * | 6 | `{app}.{module}.{section}.{resource}.read.all` | resource (scope tier)| * * Disambiguation: `all` is NOT one of the 12 actions, so a path ending in * `all` can only be a scope tier — it is one iff the previous segment is * `read`, otherwise the path is malformed. The scope tier is legal ONLY at * the section and resource grains (an app/module-grain `read.all` is rejected * loudly instead of seeding a NULL-action row like the historical parser * did). The single residual collision is a NODE literally coded `read` or * `all` — hence {@link RESERVED_NODE_CODES}, enforced by scaffold-core-seed * validate rule 11 and flagged by derive-permission-floor. */ /** The 12 actions — order mirrors the platform `PermissionAction` enum. */ export const PERMISSION_ACTIONS = [ 'access', 'read', 'create', 'update', 'delete', 'export', 'import', 'approve', 'reject', 'assign', 'execute', 'lookup', ] as const export type PermissionActionName = (typeof PERMISSION_ACTIONS)[number] /** * The row-level scope tier suffix — lives in the PATH, not in the enum. * The platform stores tier rows with `Action = Read` (see the generated * `ParseAction` and derive-seed-delta's SQL `Action` literals). */ export const SCOPE_TIER_ACTION = 'read.all' /** * action (or scope tier) → C# `PermissionAction` member name, which is ALSO * the SQL literal stored in `core.nav_Permissions.Action` (string-converted * enum). Keys are the closed set every generated `ParseAction` switch and * derive-seed-delta's `ACTION_NAMES` must carry — drift-locked. */ export const PERMISSION_ACTION_ENUM: Readonly> = { access: 'Access', read: 'Read', create: 'Create', update: 'Update', delete: 'Delete', export: 'Export', import: 'Import', approve: 'Approve', reject: 'Reject', assign: 'Assign', execute: 'Execute', lookup: 'Lookup', [SCOPE_TIER_ACTION]: 'Read', } /** * STRUCTURAL actions — they describe no data operation. `access` is the * v3.62 menu/route visibility lock; `lookup` is the FK reference surface. * scaffold-controller emits their constants unconditionally, which is why * they are excluded from {@link PERMISSION_DATA_ACTIONS}. */ export const PERMISSION_STRUCTURAL_ACTIONS = ['access', 'lookup'] as const /** * DATA actions a caller may request on scaffold-controller's `actions[]` * (10 = the 12 minus the structural pair). Order mirrors the enum. */ export const PERMISSION_DATA_ACTIONS = [ 'read', 'create', 'update', 'delete', 'export', 'import', 'approve', 'reject', 'assign', 'execute', ] as const /** * THE FLOOR — the 7 actions every data-bearing nav node gets by default: * the exact set of constants scaffold-controller emits (Access + Lookup + * the default CRUD) plus `execute` for custom actions. Invariant sought: * one emitted C# constant = one seeded permission row (DEV-API-021 green by * construction). */ export const PERMISSION_FLOOR_ACTIONS = [ 'access', 'lookup', 'read', 'create', 'update', 'delete', 'execute', ] as const /** The 4 navigation grains — mirrors the platform `PermissionLevel` enum. */ export type PermissionGrain = 'application' | 'module' | 'section' | 'resource' export const PERMISSION_GRAINS: readonly PermissionGrain[] = [ 'application', 'module', 'section', 'resource', ] as const /** * Effective floor per grain. App/module carry ONLY the `.access` visibility * lock (their data lives in their sections); section and resource carry the * full 7. NB: the platform's visibility rule reads module/section `.access` * only — the app/resource rows exist for the admin matrix (deliberate). */ export const FLOOR_BY_GRAIN: Readonly> = { application: ['access'], module: ['access'], section: PERMISSION_FLOOR_ACTIONS, resource: PERMISSION_FLOOR_ACTIONS, } /** * Node codes that would break the 5/6-segment disambiguation (a section or * resource coded `read`/`all` makes `….read.all` unparseable). Enforced by * scaffold-core-seed validate rule 11; surfaced by derive-permission-floor. */ export const RESERVED_NODE_CODES = ['read', 'all'] as const /** kebab-case segment — same alphabet as every existing path regex. */ const SEGMENT_RE = /^[a-z][a-z0-9-]*$/ const ACTION_SET: ReadonlySet = new Set(PERMISSION_ACTIONS) export function isPermissionAction(x: string): x is PermissionActionName { return ACTION_SET.has(x) } /** C# enum member / SQL literal for an action (incl. `read.all`), or null. */ export function permissionActionEnumName(action: string): string | null { return PERMISSION_ACTION_ENUM[action] ?? null } export interface ParsedPermissionPath { grain: PermissionGrain appCode: string moduleCode?: string sectionCode?: string resourceCode?: string /** Code of the BEARING node (= the last code before the action). */ nodeCode: string /** Path of the bearing node, action stripped. */ nodePath: string /** The action — possibly the composite `read.all` scope tier. */ action: string isScopeTier: boolean } /** * Parse a permission path into its grain + segments. Returns null on any * malformed path: unknown action, bad segment alphabet, app/module-grain * `read.all`, 7+ segments. See the module header for the grammar table. */ export function parsePermissionPath(path: string): ParsedPermissionPath | null { const parts = path.split('.') if (parts.length < 2 || parts.length > 6) return null if (!parts.every((p) => SEGMENT_RE.test(p))) return null const last = parts[parts.length - 1] // Scope tier: `…read.all`. `all` is not an action, so a path ending in // `all` can ONLY be a tier — and only at the section (5-seg) or resource // (6-seg) grain. Everything else ending in `all` is malformed. if (last === 'all') { if (parts[parts.length - 2] !== 'read') return null if (parts.length === 5) { const [appCode, moduleCode, sectionCode] = parts return { grain: 'section', appCode, moduleCode, sectionCode, nodeCode: sectionCode, nodePath: `${appCode}.${moduleCode}.${sectionCode}`, action: SCOPE_TIER_ACTION, isScopeTier: true, } } if (parts.length === 6) { const [appCode, moduleCode, sectionCode, resourceCode] = parts return { grain: 'resource', appCode, moduleCode, sectionCode, resourceCode, nodeCode: resourceCode, nodePath: `${appCode}.${moduleCode}.${sectionCode}.${resourceCode}`, action: SCOPE_TIER_ACTION, isScopeTier: true, } } return null // app/module-grain tier — rejected loudly, never seeded silently } if (!isPermissionAction(last)) return null if (parts.length === 6) return null // 6-seg is the resource tier ONLY const nodeSegments = parts.slice(0, -1) const nodePath = nodeSegments.join('.') const [appCode, moduleCode, sectionCode, resourceCode] = nodeSegments const grain = PERMISSION_GRAINS[nodeSegments.length - 1] return { grain, appCode, moduleCode, sectionCode, resourceCode, nodeCode: nodeSegments[nodeSegments.length - 1], nodePath, action: last, isScopeTier: false, } } /** True iff the path parses under the multi-grain grammar. */ export function isValidPermissionPath(path: string): boolean { return parsePermissionPath(path) !== null } /** Grain of an action-less node path (1..4 segments), or null. */ export function grainOfNodePath(nodePath: string): PermissionGrain | null { const parts = nodePath.split('.') if (parts.length < 1 || parts.length > 4) return null if (!parts.every((p) => SEGMENT_RE.test(p))) return null return PERMISSION_GRAINS[parts.length - 1] } /** * Compose `{nodePath}.{action}` after validating both sides. Throws on a * malformed nodePath or unknown action — composition happens at generation * time where a silent skip would hide a floor regression. */ export function buildPermissionPath(nodePath: string, action: string): string { if (grainOfNodePath(nodePath) === null) { throw new Error(`buildPermissionPath: malformed node path "${nodePath}"`) } if (!isPermissionAction(action) && action !== SCOPE_TIER_ACTION) { throw new Error(`buildPermissionPath: unknown action "${action}"`) } return `${nodePath}.${action}` } /** * The floor paths of a node, derived from its grain — THE function both * scaffold-core-seed (actual seed) and derive-permission-floor (rbac.md * mirror block) call, so the two can never disagree. */ export function floorPathsForNode(nodePath: string): string[] { const grain = grainOfNodePath(nodePath) if (grain === null) { throw new Error(`floorPathsForNode: malformed node path "${nodePath}"`) } return FLOOR_BY_GRAIN[grain].map((action) => `${nodePath}.${action}`) } /** * Reserved-code guard for module/section/resource codes (and their * `previousCodes` aliases). Returns the violation message, or null when the * code is safe. */ export function validateNodeCode(code: string): string | null { if ((RESERVED_NODE_CODES as readonly string[]).includes(code)) { return ( `node code "${code}" is reserved — a section or resource coded ` + `"read" or "all" breaks the \`….read.all\` scope-tier disambiguation ` + `of permission paths` ) } return null } /** Marker carried by every inline copy of the 12-action vocabulary. */ export const PERMISSION_ACTIONS_MARKER = 'permission-actions:v1' /** Marker carried by every inline copy of the per-grain floor table. */ export const PERMISSION_FLOOR_MARKER = 'permission-floor:v1' /** * Canonical markdown block of the action vocabulary. The drift test does NOT * require carriers to embed this verbatim — it extracts every backticked * single-word lowercase token between the markers and compares the SET to * {@link PERMISSION_ACTIONS}, so carriers keep their own prose/columns. */ export function renderActionVocabularyBlock(): string { return [ ``, PERMISSION_ACTIONS.map((a) => `\`${a}\``).join(' | '), ``, ].join('\n') } /** * Canonical markdown table of the per-grain floor (markers included). The * drift test parses the data rows (grain + backticked actions) and compares * them to {@link FLOOR_BY_GRAIN} — surrounding prose stays free but the * table itself must match in every carrier. */ export function renderPermissionFloorBlock(): string { const nodeShape: Record = { application: '`{app}`', module: '`{app}.{module}`', section: '`{app}.{module}.{section}`', resource: '`{app}.{module}.{section}.{resource}`', } const rows = PERMISSION_GRAINS.map((g) => { const label = g.charAt(0).toUpperCase() + g.slice(1) const actions = FLOOR_BY_GRAIN[g].map((a) => `\`${a}\``).join(' ') return `| ${label} | ${nodeShape[g]} | ${actions} |` }).join('\n') return [ ``, '| Grain | Node | Floor permissions |', '|---|---|---|', rows, ``, ].join('\n') }