/** * lib/ba-rbac-rows.ts — shared rbac.md HUMAN-row parser. * * Row-level parsing of a module's `rbac.md` permission matrix at the * `(actor × permission × portée)` granularity. The machine-owned derived- * lookups block is stripped first (its rows carry the same `| BA-… |` shape * and would otherwise pollute the human matrix); the `ba:rbac-floor` block * needs no stripping — its rows have no actor column, so ROW_RE never * matches them. * * Consumers: * - business-analyse/create-rbac/cli/derive-lookup-grants — grantee * derivation + skip rule (re-exports this module from its historical * `rbac-rows.ts` / `block.ts` paths, so its tests stay untouched) * - documentation/cli/extract-doc — Portée/actor enrichment of the * « Accès & rôles » doc section * Living in lib/ (not in a skill folder) matters: the skill installer * flattens/renames the business-analyse skill folders, which breaks * cross-SKILL relative imports — lib/ is depth-rewritten safely. */ import { readdirSync, readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' export const DERIVED_BLOCK_BEGIN = '' export const DERIVED_BLOCK_END = '' /** Source with the machine-owned block removed — the HUMAN-authored matrix only. */ export function stripDerivedBlock(source: string): string { const beginIdx = source.indexOf(DERIVED_BLOCK_BEGIN) const endIdx = source.indexOf(DERIVED_BLOCK_END) if (beginIdx < 0 || endIdx <= beginIdx) return source const lineStart = source.lastIndexOf('\n', beginIdx) + 1 const lineEnd = source.indexOf('\n', endIdx) return source.slice(0, lineStart) + (lineEnd >= 0 ? source.slice(lineEnd + 1) : '') } /** One `(actor × permission × portée)` row of a module matrix. */ export interface RbacRow { actorCode: string actorLabel?: string /** Backtick path verbatim (module-scoped 2/3/4 segments, or app-qualified). */ path: string portee: string } /** * `| BA-001-AC-001 (Commercial) | `crm.pipeline.opportunites.read` | toutes |` * The label parenthesis is optional; the Portée cell is captured verbatim. */ export const ROW_RE = /^\|\s*(BA-[A-Za-z0-9-]+-AC-\d+)\s*(?:\(([^)]*)\))?\s*\|\s*`([^`]+)`\s*\|\s*([^|]*?)\s*\|/gm /** * Case/trim-insensitive identity between an rbac.md actor and a seeded role. * The three legs (label⇔name, label⇔code, code⇔code) cover both regenerated * projects (role.name = actor label by construction — lib/ba-actors.ts) and * legacy hand-coded roles. Shared by documentation/extract-doc (« Accès & * rôles » enrichment) and create-rbac/derive-rbac-grants (DEV-CORE-011 * parity check) — one identity, never two drifting copies. */ export function actorMatchesRole( row: Pick, role: { code: string; name: string }, ): boolean { const label = row.actorLabel?.trim().toLowerCase() return ( (label !== undefined && (label === role.name.trim().toLowerCase() || label === role.code.trim().toLowerCase())) || row.actorCode.trim().toLowerCase() === role.code.trim().toLowerCase() ) } /** Parse the HUMAN rows of an rbac.md content (machine block stripped). */ export function parseRbacRows(content: string): RbacRow[] { const human = stripDerivedBlock(content) const rows: RbacRow[] = [] for (const m of human.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 } /** * Load + parse `///rbac.md`. `exists: false` when absent. * * Folder resolution is CASE-INSENSITIVE (`resolveBaModuleDir`): callers hand * over codes typed by a human — `derive-lookup-grants` passes the * `(APP/MODULE)` of an entité.md cross-module relation — and on Linux a * case-sensitive join would silently return `[]`, which in the lookup * derivation reads as "this actor holds nothing" and emits a redundant grant. */ export function loadModuleRbacRows( baRoot: string, app: string, module: string, ): { exists: boolean; rows: RbacRow[] } { const resolved = resolveBaModuleDir(baRoot, app, module) if (!resolved) return { exists: false, rows: [] } const rbacPath = join(baRoot, resolved.app, resolved.module, 'rbac.md') if (!existsSync(rbacPath)) return { exists: false, rows: [] } try { return { exists: true, rows: parseRbacRows(readFileSync(rbacPath, 'utf8')) } } catch { return { exists: false, rows: [] } } } /** * Case-insensitive resolution of a module directory under the BA root. BA * app/module folders are UPPERCASE codes while consumers usually hold the * lowercase nav codes (navRoute segments) — a case-sensitive join would miss * them on Linux. Returns the ACTUAL folder names, or null when either level * is absent. */ export function resolveBaModuleDir( baRoot: string, appCode: string, moduleCode: string, ): { app: string; module: string } | null { const findDir = (parent: string, code: string): string | null => { let entries try { entries = readdirSync(parent, { withFileTypes: true }) } catch { return null } const lower = code.toLowerCase() const hit = entries.find((e) => e.isDirectory() && e.name.toLowerCase() === lower) return hit ? hit.name : null } const app = findDir(baRoot, appCode) if (!app) return null const module = findDir(join(baRoot, app), moduleCode) return module ? { app, module } : null }