/** * cli:audit-ba — rules/rbac.ts (RBAC-001..012 — module scope). * Mirrors business-analyse/audit-rbac/SKILL.md. RBAC-001..007/010/011 read the * HUMAN matrix (lib/ba-rbac-rows already strips the machine block); * RBAC-008/009/012 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 { resolveEntitySectionFolder } from '../../../../../lib/ba-screens.js' import { formatSensitivity, isRestricted, isSensitive, rankOf, resolveEntitySensitivity, type ResolvedSensitivity, } from '../../../../../lib/ba-sensitivity.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-011..012 — data sensitivity (lib/ba-sensitivity, doctrine 2026-10-03): // reading a section bound to a ≥ confidentiel entity needs a USE CASE for // the actor (011), and a derived lookup grant onto such a producer waits // for a HUMAN row (012). // ------------------------------------------------------------------------- { id: 'RBAC-011', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! if (m.rbacRows.length === 0 || m.entities.length === 0) { return { findings: [finding('RBAC-011', DIM, 'ok', moduleScope(m), 'Matrice vide ou modèle de données non rédigé — vérification différée.')] } } const sensitive = sensitiveSectionsOf(m) if (sensitive.size === 0) { return { findings: [finding('RBAC-011', DIM, 'ok', moduleScope(m), 'Aucune section du module liée à une entité ≥ confidentiel.')] } } const actors = new Map((model.apps.find((a) => a.app === m.app)?.actors ?? []).map((a) => [a.code.toUpperCase(), a])) const modNav = navCodeOf(m.module) // One evidence line per (actor × sensitive section), every covering row listed. const unjustified = new Map() const external: string[] = [] for (const r of m.rbacRows) { for (const section of coveredSensitiveSections(r, modNav, sensitive)) { const bound = sensitive.get(section)! const actorKey = r.actorCode.toUpperCase() const actorLabel = r.actorLabel ? `${r.actorCode} (${r.actorLabel})` : r.actorCode if (actors.get(actorKey)?.type === 'external' && isRestricted(bound.sensitivity)) { external.push(`${actorLabel} — acteur \`external\` × \`${r.path}\` → section « ${section} » (${bound.entity}, ${formatSensitivity(bound.sensitivity)}) : un acteur externe n’accède jamais à une donnée restreinte`) continue } if (/d[ée]cision\s+client/i.test(r.justification ?? '')) continue if (actorHasUseCaseInSection(m, actorKey, section)) continue const key = `${actorKey}|${section}` const prev = unjustified.get(key) if (prev) prev.rows.push(`\`${r.path}\``) else unjustified.set(key, { actor: actorLabel, section, rows: [`\`${r.path}\``] }) } } const needToKnow = [...unjustified.values()].map( (u) => { const bound = sensitive.get(u.section)! return `${u.actor} × ${u.rows.join(', ')} → section « ${u.section} » (${bound.entity}, ${formatSensitivity(bound.sensitivity)}) : aucun cas d’usage de la section ne nomme cet acteur (principal ou secondaire) — ajoute l’UC qui justifie l’accès, ou une 4e cellule \`décision client : \` sur la ligne` }, ) const findings = [] if (external.length > 0) { findings.push(finding('RBAC-011', DIM, 'err', moduleScope(m), 'Acteur externe sur une donnée restreinte — jamais : un `restreint` est nominatif et interne par définition (prohibition n°13 de /ba-create-rbac).', external)) } if (needToKnow.length > 0) { findings.push(finding('RBAC-011', DIM, 'warn', moduleScope(m), 'Besoin d’en connaître — un acteur lit, exporte ou consulte (lookup) une section liée à une entité ≥ confidentiel sans qu’aucun cas d’usage de cette section ne le nomme. L’accès à une donnée sensible se justifie par un usage, jamais par habitude.', needToKnow)) } if (findings.length === 0) { findings.push(finding('RBAC-011', DIM, 'ok', moduleScope(m), `${sensitive.size} section(s) sensible(s) — chaque accès en lecture est porté par un cas d’usage ou une décision client.`)) } return { findings } }, }, { id: 'RBAC-012', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! return { findings: [pendingSensitiveGrants(model, m)] } }, }, ] // --------------------------------------------------------------------------- // RBAC-011 / RBAC-012 helpers — sensitivity at the section grain // --------------------------------------------------------------------------- /** Actions that READ a section's data — the need-to-know surface. */ const NEED_TO_KNOW_ACTIONS: ReadonlySet = new Set(['read', 'read.all', 'export', 'lookup']) const ACTOR_CODE_RE = /BA-[A-Z0-9]+-AC-\d+/ interface SensitiveSection { entity: string sensitivity: ResolvedSensitivity } /** * The module's sections bound to a ≥ confidentiel entity, keyed by NAV code — * the SAME section ladder derive-lookup-grants uses (list screen in the * module, else kebab-plural among the menu sections); an unresolvable entity * is RBAC-008's `needsResolution`, not a silent section here. */ function sensitiveSectionsOf(m: ModuleModel): Map { const out = new Map() for (const e of m.entities) { const r = resolveEntitySensitivity(e, m.entities) if (!isSensitive(r)) continue const res = resolveEntitySectionFolder(m.screens, m.sections, e.name, m.module) if (!res.section) continue const nav = navCodeOf(res.section) const prev = out.get(nav) if (!prev || rankOf(r.level) > rankOf(prev.sensitivity.level)) out.set(nav, { entity: e.name, sensitivity: r }) } return out } /** * The sensitive sections a HUMAN row reaches: a section-grain (or resource- * grain) path names one; a module-grain path (`paie.read`) covers them all. * Module-scoped (2..4 segments) and app-qualified (3..5) spellings both read. */ function coveredSensitiveSections(row: RbacRow, modNav: string, sensitive: Map): string[] { if (!NEED_TO_KNOW_ACTIONS.has(actionOf(row.path))) return [] const segs = targetOf(row.path).split('.') let rest: string[] if (segs[0] === modNav) rest = segs.slice(1) else if (segs[1] === modNav) rest = segs.slice(2) else return [] if (rest.length === 0) return [...sensitive.keys()] return sensitive.has(rest[0]!) ? [rest[0]!] : [] } /** The actor is primary or secondary actor of ≥ 1 use case of the section (XD-004's actor matching). */ function actorHasUseCaseInSection(m: ModuleModel, actorCode: string, sectionNav: string): boolean { return m.ucs.some((uc) => { if (navCodeOf(uc.sectionFolder) !== sectionNav) return false return [uc.primaryActor ?? '', ...uc.secondaryActors].some((a) => a.toUpperCase().match(ACTOR_CODE_RE)?.[0] === actorCode) }) } /** RBAC-012 — the SAME engine + sources as RBAC-008, reading `pendingConfirmation`. */ function pendingSensitiveGrants(model: CorpusModel, m: ModuleModel) { if (!m.rbacExists || m.rbacContent === null) { return finding('RBAC-012', 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-012', DIM, 'ok', moduleScope(m), 'Modèle de données non rédigé — vérification différée au run post-data-model.') } try { const core = deriveLookupGrants(m.app, m.module, fsLookupSources(model.baRoot, m.rbacRows, model.graph)) if (core.pendingConfirmation.length > 0) { return finding( 'RBAC-012', DIM, 'warn', moduleScope(m), 'Grants `lookup` dérivés en attente de validation humaine — le producteur est une entité ≥ confidentiel, le CLI n’émet rien ; une ligne HUMAINE `lookup` ou `read` de l’acteur sur la section productrice vaut validation (le candidat passe alors en « déjà couvert »).', core.pendingConfirmation.map((p) => `${p.actorCode}${p.actorLabel ? ` (${p.actorLabel})` : ''} → \`${p.path}\` (${p.sensitivity}) : ${p.justification}`), ) } return finding('RBAC-012', DIM, 'ok', moduleScope(m), 'Aucun grant lookup dérivé en attente sur un producteur sensible.') } catch (e) { return finding('RBAC-012', DIM, 'warn', moduleScope(m), `Vérification impossible : ${(e as Error).message}`) } } // --------------------------------------------------------------------------- // 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}`) } }