/** * cli:derive-rbac-grants — derive.ts * * Pure derivation core. Sources are injected (fs-free) so the tests exercise * the exact production paths on literal fixtures. * * Semantics mechanized from phases-detail.md § "RBAC transcription" and * scaffold-core-seed/build-spec.ts (drift-tested against the latter): * * 1. HUMAN rows are module-scoped (1..4 segments) → prefix the app nav code. * An already-app-qualified human row is transported verbatim + warned * (create-rbac prohibition #10 says it should not exist). * 2. DERIVED-block rows (`ba:rbac-derived-lookups`) are already app-qualified * → transported VERBATIM, never re-prefixed (cross-app + platform paths * included). * 3. The FLOOR is never transcribed — floor paths are excluded from the * authored `permissions[]` (they exist by construction), and the floor's * `.access` grants are grandfathered here exactly like * `deriveAccessGrants`: a role gets `{node}.access` iff it holds ≥ 1 * non-lookup grant under the node and the node's `.access` belongs to a * processed app's floor. * 4. Attribution: a GRANT lands in the slice of the app that OWNS the * actor's role; a PERMISSION row stays with the app its path roots at. * 5. Never guess: an unresolvable actor / path becomes a needsResolution * entry — never a silent drop, never an invented grant. */ import { floorPathsForNode, parsePermissionPath, } from '../../../../lib/permission-actions.js' import { slugifyRoleCode } from '../../../../lib/string-utils.js' import { mapRoleCategory, type BaActor } from '../../../../lib/ba-actors.js' import { DERIVED_BLOCK_BEGIN, DERIVED_BLOCK_END, ROW_RE, type RbacRow, } from '../../../../lib/ba-rbac-rows.js' import { navCodeOf } from '../derive-lookup-grants/derive.js' /** Rows of the machine-owned derived-lookups block (same `| BA-… |` shape as * the human matrix — the block is BETWEEN the markers, so `parseRbacRows`, * which strips it, never sees them). */ export function parseDerivedRows(content: string): RbacRow[] { const beginIdx = content.indexOf(DERIVED_BLOCK_BEGIN) const endIdx = content.indexOf(DERIVED_BLOCK_END) if (beginIdx < 0 || endIdx <= beginIdx) return [] const block = content.slice(beginIdx, endIdx) const rows: RbacRow[] = [] for (const m of block.matchAll(new RegExp(ROW_RE.source, ROW_RE.flags))) { rows.push({ actorCode: m[1], actorLabel: m[2]?.trim() || undefined, path: m[3].trim(), portee: m[4].trim(), }) } return rows } import type { AppFragment, GrantFragment, NeedsResolutionEntry, PermissionFragment, RoleFragment, } from './types.js' export interface GrantSources { /** BA application folders to process (e.g. `["CRM"]`). */ appFolders: string[] /** Parsed `/acteur.md`, or null when the file is absent. */ actorsOf: (appFolder: string) => BaActor[] | null /** ALL module folders of an app (floor nodes — with or without rbac.md). */ modulesOf: (appFolder: string) => string[] /** Human rows of `//rbac.md` (null = file absent). */ humanRowsOf: (appFolder: string, moduleFolder: string) => RbacRow[] | null /** Rows of the module's machine-owned derived-lookups block. */ derivedRowsOf: (appFolder: string, moduleFolder: string) => RbacRow[] /** Section folders (lower-kebab) of a module. */ sectionsOf: (appFolder: string, moduleFolder: string) => string[] /** Resource folders (lower-kebab) of a section. */ resourcesOf: (appFolder: string, moduleFolder: string, section: string) => string[] } export interface DeriveResult { apps: AppFragment[] needsResolution: NeedsResolutionEntry[] warnings: string[] totals: { apps: number actors: number humanGrants: number derivedLookupGrants: number accessGrandfathered: number permissions: number needsResolution: number } } interface ResolvedActor { role: RoleFragment actor: BaActor } export function deriveRbacGrants(sources: GrantSources): DeriveResult { const needsResolution: NeedsResolutionEntry[] = [] const warnings: string[] = [] // ── Actors → roles, per app ──────────────────────────────────────────── const actorsByApp = new Map>() const rolesByApp = new Map() for (const app of sources.appFolders) { const appCode = navCodeOf(app) const actors = sources.actorsOf(app) const byCode = new Map() const roles: RoleFragment[] = [] if (actors !== null) { const bySlug = new Map() for (const actor of actors) { const slug = slugifyRoleCode(actor.label) const holder = bySlug.get(slug) if (holder !== undefined) { needsResolution.push({ kind: 'role-code-collision', app, detail: `Actors ${holder} and ${actor.code} both slug to role code "${slug}" — ` + `rename one actor label in acteur.md so each seeds a distinct role.`, }) } else { bySlug.set(slug, actor.code) } const { category, warning } = mapRoleCategory(actor.categorie) if (warning) warnings.push(`${app}/acteur.md ${actor.code}: ${warning}`) const role: RoleFragment = { code: slug, name: actor.label, applicationCode: appCode, ...(category !== undefined ? { category } : {}), baCode: actor.code, } byCode.set(actor.code, { role, actor }) roles.push(role) } } actorsByApp.set(app, byCode) rolesByApp.set(app, roles) } // ── Floor paths of every processed app (exclusion set + grandfathering) ─ const floorPaths = new Set() const floorAccessPaths = new Set() for (const app of sources.appFolders) { const appCode = navCodeOf(app) const nodePaths: string[] = [appCode] for (const module of sources.modulesOf(app)) { const modulePath = `${appCode}.${navCodeOf(module)}` nodePaths.push(modulePath) for (const section of sources.sectionsOf(app, module)) { const sectionPath = `${modulePath}.${section}` nodePaths.push(sectionPath) for (const resource of sources.resourcesOf(app, module, section)) { nodePaths.push(`${sectionPath}.${resource}`) } } } for (const nodePath of nodePaths) { let paths: string[] try { paths = floorPathsForNode(nodePath) } catch { warnings.push(`Floor skipped for malformed node path "${nodePath}" (BA folder naming).`) continue } for (const p of paths) { floorPaths.add(p) if (p.endsWith('.access')) floorAccessPaths.add(p) } } } // ── Rows → grants ────────────────────────────────────────────────────── const grantsByApp = new Map() const grantSeen = new Set() // Authored permission rows keyed by path (producer-app attribution). const authoredRoles = new Map>() let humanGrants = 0 let derivedLookupGrants = 0 const addGrant = (ownerApp: string, roleCode: string, path: string): boolean => { const key = `${ownerApp}|${roleCode}|${path}` if (grantSeen.has(key)) return false grantSeen.add(key) const arr = grantsByApp.get(ownerApp) ?? [] arr.push({ roleCode, permissionPath: path }) grantsByApp.set(ownerApp, arr) return true } const resolveActor = ( app: string, module: string, row: RbacRow, ): ResolvedActor | null => { const resolved = actorsByApp.get(app)?.get(row.actorCode) if (!resolved) { needsResolution.push({ kind: 'unknown-actor', app, module, detail: `Row actor ${row.actorCode}${row.actorLabel ? ` (${row.actorLabel})` : ''} on \`${row.path}\` ` + `is not declared in ${app}/acteur.md — declare the actor (or fix the code), then re-run.`, }) return null } if ( row.actorLabel && row.actorLabel.trim().toLowerCase() !== resolved.actor.label.trim().toLowerCase() ) { warnings.push( `${app}/${module}/rbac.md: row label "${row.actorLabel}" differs from acteur.md label ` + `"${resolved.actor.label}" for ${row.actorCode} — acteur.md wins.`, ) } return resolved } for (const app of sources.appFolders) { const appCode = navCodeOf(app) const acteurMissing = sources.actorsOf(app) === null for (const module of sources.modulesOf(app)) { const humanRows = sources.humanRowsOf(app, module) const derivedRows = sources.derivedRowsOf(app, module) if (humanRows === null) continue // no rbac.md for this module if (acteurMissing && (humanRows.length > 0 || derivedRows.length > 0)) { needsResolution.push({ kind: 'missing-acteur', app, module, detail: `${app}/${module}/rbac.md has matrix rows but ${app}/acteur.md is absent — run /ba-create-actors first.`, }) continue } const moduleNav = navCodeOf(module) for (const row of humanRows) { const resolved = resolveActor(app, module, row) if (!resolved) continue let qualified: string if (row.path.split('.')[0] === appCode) { warnings.push( `${app}/${module}/rbac.md: human row \`${row.path}\` is already app-qualified ` + `(prohibition #10) — transported verbatim, not re-prefixed.`, ) qualified = row.path } else { qualified = `${appCode}.${row.path}` } const parsed = parsePermissionPath(qualified) if (parsed === null) { needsResolution.push({ kind: 'invalid-path', app, module, detail: `Human row \`${row.path}\` (→ \`${qualified}\`) does not parse under the multi-grain ` + `grammar (lib/permission-actions.ts) — fix the row, then re-run.`, }) continue } if (parsed.grain !== 'application' && parsed.moduleCode !== moduleNav) { warnings.push( `${app}/${module}/rbac.md: row \`${row.path}\` roots at module "${parsed.moduleCode}" ` + `instead of "${moduleNav}" — transported as written, verify the matrix.`, ) } if (addGrant(app, resolved.role.code, qualified)) humanGrants++ if (!floorPaths.has(qualified)) { const roles = authoredRoles.get(qualified) ?? new Set() roles.add(resolved.role.code) authoredRoles.set(qualified, roles) } } for (const row of derivedRows) { const resolved = resolveActor(app, module, row) if (!resolved) continue const parsed = parsePermissionPath(row.path) if (parsed === null) { needsResolution.push({ kind: 'unparsable-derived-row', app, module, detail: `Derived-block row \`${row.path}\` does not parse — re-run derive-lookup-grants ` + `(the block is machine-owned, never hand-edited).`, }) continue } if (parsed.action !== 'lookup') { warnings.push( `${app}/${module}/rbac.md: derived-block row \`${row.path}\` is not a .lookup path — ` + `transported verbatim, but the block should only carry derived lookups.`, ) } if (addGrant(app, resolved.role.code, row.path)) derivedLookupGrants++ if (!floorPaths.has(row.path)) { const roles = authoredRoles.get(row.path) ?? new Set() roles.add(resolved.role.code) authoredRoles.set(row.path, roles) } } } } // ── v3.62 `.access` grandfathering (mirror of deriveAccessGrants) ────── let accessGrandfathered = 0 for (const [ownerApp, grants] of [...grantsByApp.entries()]) { for (const grant of [...grants]) { const parsed = parsePermissionPath(grant.permissionPath) if (!parsed) continue if (parsed.action === 'lookup') continue // the v3.62 menu-leak guard const segments = parsed.nodePath.split('.') for (let i = 1; i <= segments.length; i++) { const accessPath = `${segments.slice(0, i).join('.')}.access` if (!floorAccessPaths.has(accessPath)) continue if (addGrant(ownerApp, grant.roleCode, accessPath)) accessGrandfathered++ } } } // ── Assemble per-app fragments ───────────────────────────────────────── const appCodes = new Map(sources.appFolders.map((app) => [navCodeOf(app), app])) const apps: AppFragment[] = sources.appFolders.map((app) => { const appCode = navCodeOf(app) const permissions: PermissionFragment[] = [...authoredRoles.entries()] .filter(([path]) => { const parsed = parsePermissionPath(path) return parsed !== null && parsed.appCode === appCode }) .map(([path, roles]) => ({ path, roles: [...roles].sort() })) .sort((a, b) => a.path.localeCompare(b.path)) const rolePermissions = (grantsByApp.get(app) ?? []).sort( (a, b) => a.roleCode.localeCompare(b.roleCode) || a.permissionPath.localeCompare(b.permissionPath), ) const actors = (rolesByApp.get(app) ?? []).slice().sort((a, b) => a.code.localeCompare(b.code)) return { app, appCode, actors, permissions, rolePermissions } }) // Authored paths rooting at an app OUTSIDE the processed set (platform, // e.g. `administration.users.lookup`): the grant transports (role-owner // slice) but no Permission row is ours to author — the owner seeds it. for (const path of authoredRoles.keys()) { const parsed = parsePermissionPath(path) if (parsed && !appCodes.has(parsed.appCode)) { warnings.push( `Path \`${path}\` roots at app "${parsed.appCode}" outside the processed set — grant transported, ` + `Permission row left to that app's owner (platform rows ship with the platform seed).`, ) } } const totals = { apps: apps.length, actors: apps.reduce((n, a) => n + a.actors.length, 0), humanGrants, derivedLookupGrants, accessGrandfathered, permissions: apps.reduce((n, a) => n + a.permissions.length, 0), needsResolution: needsResolution.length, } return { apps, needsResolution, warnings, totals } }