/** * cli:audit-ba — rules/rbac.ts (RBAC-001..010 — module scope). * Mirrors business-analyse/audit-rbac/SKILL.md. RBAC-001..007/010 read the * HUMAN matrix (lib/ba-rbac-rows already strips the machine block); * RBAC-008/009 reuse the create-rbac derive engines by DIRECT IMPORT (never a * spawn) AND their shared source factories + drift layer (`fsLookupSources` / * `checkLookupDrift`, `fsFloorSources`) — never a hand-rolled second * construction. Reconstructing the lookup sources from the SCOPE-FILTERED * corpus is what made RBAC-008 unclosable: producers outside the audited * module contributed no rows, so the engine's `holds-read` skip could not * fire and the rule demanded grants the writer had rightly skipped. */ import type { RbacRow } from '../../../../../lib/ba-rbac-rows.js' import { deriveLookupGrants, fsLookupSources, navCodeOf, } from '../../../../create-rbac/cli/derive-lookup-grants/derive.js' import { checkLookupDrift } from '../../../../create-rbac/cli/derive-lookup-grants/block.js' import { derivePermissionFloor, fsFloorSources } from '../../../../create-rbac/cli/derive-permission-floor/derive.js' import { extractFloorBlock, extractFloorRowLines, renderFloorRowLine, } from '../../../../create-rbac/cli/derive-permission-floor/block.js' import type { CorpusModel, ModuleModel } from '../corpus/model.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' const DIM = 'rbac' as const // --------------------------------------------------------------------------- // Portée vocabulary (audit-rbac RBAC-006/007) // --------------------------------------------------------------------------- type Portee = 'all' | 'own' | 'assigned' | 'team' | 'custom' | 'unknown' const PORTEE_MAP: Record = { '': 'all', all: 'all', toutes: 'all', tous: 'all', own: 'own', 'les siennes': 'own', 'les leurs': 'own', assigned: 'assigned', attribuees: 'assigned', 'les attribuees': 'assigned', team: 'team', equipe: 'team', custom: 'custom', personnalisee: 'custom', } function porteeOf(cell: string): { portee: Portee; clause: string } { const folded = fold(cell) const head = folded.split(/[:—-]/)[0]!.trim() const clause = cell.includes(':') ? cell.slice(cell.indexOf(':') + 1).trim() : '' if (folded in PORTEE_MAP) return { portee: PORTEE_MAP[folded]!, clause } if (head in PORTEE_MAP) return { portee: PORTEE_MAP[head]!, clause } return { portee: 'unknown', clause } } /** Action = last segment; `….read.all` is the two-segment scope tier. */ function actionOf(path: string): string { if (/\.read\.all$/i.test(path)) return 'read.all' const segs = path.split('.') return segs[segs.length - 1]!.toLowerCase() } function targetOf(path: string): string { const action = actionOf(path) return path.slice(0, path.length - action.length - 1).toLowerCase() } function rowLabel(r: RbacRow): string { return `${r.actorCode} × \`${r.path}\`` } // --------------------------------------------------------------------------- // Rules // --------------------------------------------------------------------------- export const RBAC_RULES: RuleDef[] = [ { id: 'RBAC-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! return { findings: [ m.rbacRows.length === 0 ? finding('RBAC-001', DIM, 'err', moduleScope(m), `${m.app}/${m.module} : aucune permission dans la matrice — lancer /ba-create-rbac.`) : finding('RBAC-001', DIM, 'ok', moduleScope(m), `${m.rbacRows.length} permission(s) dans la matrice humaine.`), ], } }, }, { id: 'RBAC-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! if (m.sections.length === 0 || m.rbacRows.length === 0) { return { findings: [finding('RBAC-002', DIM, 'ok', moduleScope(m), 'Pas de section ou matrice vide — couvert par RBAC-001/SEC-001.')] } } const modNav = navCodeOf(m.module) const missing: string[] = [] for (const section of m.sections) { const secNav = navCodeOf(section) const prefix = `${modNav}.${secNav}` const byActor = new Map>() for (const r of m.rbacRows) { const target = targetOf(r.path) if (target !== prefix && !target.startsWith(`${prefix}.`) && !target.endsWith(`.${prefix}`)) continue if (!byActor.has(r.actorCode)) byActor.set(r.actorCode, new Set()) byActor.get(r.actorCode)!.add(actionOf(r.path)) } const covered = [...byActor.values()].some((actions) => actions.has('access') && actions.has('read')) if (!covered) missing.push(section) } return { findings: [ missing.length > 0 ? finding( 'RBAC-002', DIM, 'err', moduleScope(m), `${missing.length} section(s) sans le minimum (access + read) pour au moins un acteur — compléter via /ba-create-rbac.`, missing, ) : finding('RBAC-002', DIM, 'ok', moduleScope(m), `Chaque section a le minimum access + read pour ≥ 1 acteur (${m.sections.length} section(s)).`), ], } }, }, { id: 'RBAC-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const TOXIC: [string, string][] = [ ['create', 'approve'], ['delete', 'restore'], ] const byActorTarget = new Map>() for (const r of m.rbacRows) { const key = `${r.actorCode}|${targetOf(r.path)}` if (!byActorTarget.has(key)) byActorTarget.set(key, new Set()) byActorTarget.get(key)!.add(actionOf(r.path)) } const evidence: string[] = [] for (const [key, actions] of byActorTarget) { for (const [a, b] of TOXIC) { if (actions.has(a) && actions.has(b)) { const [actor, target] = key.split('|') evidence.push(`${actor} × ${target} : ${a} + ${b}`) } } } return { findings: [ evidence.length > 0 ? finding('RBAC-003', DIM, 'warn', moduleScope(m), 'Séparation des tâches : paires toxiques détectées (le créateur ne devrait pas approuver son propre travail).', evidence) : finding('RBAC-003', DIM, 'ok', moduleScope(m), 'Aucune paire toxique create+approve / delete+restore.'), ], } }, }, { id: 'RBAC-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const app = model.apps.find((a) => a.app === m.app) if (!app || app.actors.length === 0 || m.rbacRows.length === 0) { return { findings: [finding('RBAC-004', DIM, 'ok', moduleScope(m), 'Pas d’acteurs déclarés ou matrice vide — couvert par ACT-001/RBAC-001.')] } } // Only actors ACTIVE in this application's modules can be "orphans" of // THIS module — an actor declared but used in another app is not flagged. const activeInApp = new Set() for (const mod of model.modules.filter((x) => x.app === m.app)) { for (const r of mod.rbacRows) activeInApp.add(r.actorCode.toUpperCase()) } const inModule = new Set(m.rbacRows.map((r) => r.actorCode.toUpperCase())) const orphans = app.actors .filter((a) => activeInApp.has(a.code.toUpperCase()) && !inModule.has(a.code.toUpperCase())) .map((a) => `${a.code} (${a.label})`) return { findings: [ orphans.length > 0 ? finding('RBAC-004', DIM, 'warn', moduleScope(m), `${orphans.length} acteur(s) de l'application sans aucune permission dans ce module.`, orphans) : finding('RBAC-004', DIM, 'ok', moduleScope(m), 'Tous les acteurs actifs de l’application ont ≥ 1 permission dans le module.'), ], } }, }, { id: 'RBAC-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const app = model.apps.find((a) => a.app === m.app) const known = new Set((app?.actors ?? []).map((a) => a.code.toUpperCase())) const unknown = [...new Set(m.rbacRows.map((r) => r.actorCode.toUpperCase()))].filter((c) => !known.has(c)) return { findings: [ unknown.length > 0 ? finding('RBAC-005', DIM, 'err', moduleScope(m), `${unknown.length} code(s) acteur inconnus de l'acteur.md de l'application — corriger via /ba-create-rbac.`, unknown) : finding('RBAC-005', DIM, 'ok', moduleScope(m), 'Toutes les références acteur de la matrice résolvent vers acteur.md.'), ], } }, }, { id: 'RBAC-006', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! interface ReadRow { row: RbacRow portee: Portee } const readRows: ReadRow[] = [] const readAllByActorTarget = new Set() for (const r of m.rbacRows) { const action = actionOf(r.path) if (action === 'read') readRows.push({ row: r, portee: porteeOf(r.portee).portee }) if (action === 'read.all') readAllByActorTarget.add(`${r.actorCode.toUpperCase()}|${targetOf(r.path)}`) } const scoped = readRows.some((r) => r.portee === 'own' || r.portee === 'assigned') const evidence: string[] = [] if (scoped) { for (const { row, portee } of readRows) { const key = `${row.actorCode.toUpperCase()}|${targetOf(row.path)}` if (portee === 'all' && !readAllByActorTarget.has(key)) { evidence.push(`${rowLabel(row)} : Portée « toutes » sans la ligne \`.read.all\` correspondante`) } if ((portee === 'own' || portee === 'assigned') && readAllByActorTarget.has(key)) { evidence.push(`${rowLabel(row)} : Portée « ${portee} » mais détient \`.read.all\` (la portée est silencieusement annulée)`) } } } else if (readAllByActorTarget.size > 0) { evidence.push(`module non scopé mais ${readAllByActorTarget.size} ligne(s) \`.read.all\` présente(s)`) } return { findings: [ evidence.length > 0 ? finding('RBAC-006', DIM, 'err', moduleScope(m), 'Cohérence de portée violée (matérialisation .read.all) — corriger via /ba-create-rbac.', evidence) : finding('RBAC-006', DIM, 'ok', moduleScope(m), scoped ? 'Module scopé : appariement .read.all cohérent.' : 'Module non scopé : aucune ligne .read.all.'), ], } }, }, { id: 'RBAC-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const evidence: string[] = [] for (const r of m.rbacRows) { const { portee, clause } = porteeOf(r.portee) if (portee === 'unknown') evidence.push(`${rowLabel(r)} : Portée « ${r.portee} » hors vocabulaire`) else if (portee === 'custom' && clause === '') evidence.push(`${rowLabel(r)} : Portée custom sans sa clause de filtre`) } return { findings: [ evidence.length > 0 ? finding('RBAC-007', DIM, 'err', moduleScope(m), 'Vocabulaire de Portée fermé (all/own/assigned/team/custom) violé.', evidence) : finding('RBAC-007', DIM, 'ok', moduleScope(m), 'Toutes les Portées appartiennent au vocabulaire fermé.'), ], } }, }, { id: 'RBAC-008', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! return { findings: [lookupGrantsFreshness(model, m)] } }, }, { id: 'RBAC-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! return { findings: [permissionFloorFreshness(model, m)] } }, }, { id: 'RBAC-010', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const evidence = m.rbacRows .filter((r) => { const p = porteeOf(r.portee).portee return p === 'team' || p === 'custom' }) .map((r) => `${rowLabel(r)} — Portée « ${r.portee} » : équivaut à \`toutes\` au runtime tant que la portée n'est pas matérialisée`) return { findings: [ evidence.length > 0 ? finding( 'RBAC-010', DIM, 'warn', moduleScope(m), 'Portées team/custom sans matérialisation runtime — intention documentée, restriction inexistante dans l’application déployée (garder la ligne comme intention, planifier le filtre, ou re-scoper).', evidence, ) : finding('RBAC-010', DIM, 'ok', moduleScope(m), 'Aucune Portée team/custom non matérialisée.'), ], } }, }, ] // --------------------------------------------------------------------------- // RBAC-008 / RBAC-009 — freshness via direct import of the derive engines // --------------------------------------------------------------------------- function lookupGrantsFreshness(model: CorpusModel, m: ModuleModel) { if (!m.rbacExists || m.rbacContent === null) { return finding('RBAC-008', DIM, 'ok', moduleScope(m), 'rbac.md absent — couvert par RBAC-001 ; vérification différée.') } if (!m.entityDocExists || m.entities.length === 0) { return finding('RBAC-008', DIM, 'ok', moduleScope(m), 'Modèle de données non rédigé — vérification différée au run post-data-model.') } try { // The SAME sources the CLI builds, from the SAME disk. `model.graph` is // passed because it is already full-tree and this rule runs per module — // rebuilding it would re-parse the whole BA tree once per module. const sources = fsLookupSources(model.baRoot, m.rbacRows, model.graph) const core = deriveLookupGrants(m.app, m.module, sources) const drift = checkLookupDrift(m.rbacContent, core) if (!drift.upToDate) { return finding( 'RBAC-008', DIM, 'err', moduleScope(m), 'Bloc machine des grants lookup périmé — relancer derive-lookup-grants en mode derive (jamais à la main).', [ ...(drift.missingBlock ? ['bloc absent alors que des FK candidates existent'] : []), ...drift.staleRows.map((l) => `périmée : ${l}`), ...drift.missingRows.map((l) => `manquante : ${l}`), ], ) } if (core.needsResolution.length > 0) { return finding( 'RBAC-008', DIM, 'warn', moduleScope(m), 'Producteur(s) irrésolu(s) — compléter l’écran liste ou le menu de la section productrice puis relancer le derive.', core.needsResolution.map((n) => `FK ${n.consumerEntity}.${n.fk} → ${n.targetEntity} : ${n.detail}`), ) } return finding('RBAC-008', DIM, 'ok', moduleScope(m), 'Bloc des grants lookup dérivés à jour.') } catch (e) { return finding('RBAC-008', DIM, 'warn', moduleScope(m), `Vérification de fraîcheur impossible : ${(e as Error).message}`) } } function permissionFloorFreshness(model: CorpusModel, m: ModuleModel) { if (!m.rbacExists || m.rbacContent === null) { return finding('RBAC-009', DIM, 'ok', moduleScope(m), 'rbac.md absent — couvert par RBAC-001 ; vérification différée.') } try { const core = derivePermissionFloor(m.app, m.module, fsFloorSources(model.baRoot)) const current = extractFloorBlock(m.rbacContent) const freshLines = core.rows.map(renderFloorRowLine) const fileLines = current ? extractFloorRowLines(current) : [] const freshSet = new Set(freshLines) const fileSet = new Set(fileLines) const staleRows = fileLines.filter((l) => !freshSet.has(l)) const missingRows = freshLines.filter((l) => !fileSet.has(l)) if (current === null || staleRows.length > 0 || missingRows.length > 0) { return finding( 'RBAC-009', DIM, 'err', moduleScope(m), 'Miroir du permission floor périmé (le menu a bougé après l’écriture du miroir) — relancer derive-permission-floor en mode derive.', [ ...(current === null ? ['bloc ba:rbac-floor absent'] : []), ...staleRows.map((l) => `périmée : ${l}`), ...missingRows.map((l) => `manquante : ${l}`), ], ) } if (core.warnings.length > 0) { return finding('RBAC-009', DIM, 'warn', moduleScope(m), 'Codes de nœud réservés (read/all) dans le menu — renommer la section/ressource.', core.warnings) } return finding('RBAC-009', DIM, 'ok', moduleScope(m), 'Miroir du permission floor à jour.') } catch (e) { return finding('RBAC-009', DIM, 'warn', moduleScope(m), `Vérification de fraîcheur impossible : ${(e as Error).message}`) } }