/** * cli:derive-lookup-grants — derive.ts * * Pure derivation core (all IO injected via `DeriveSources` so tests run on * in-memory fixtures) PLUS the one filesystem-backed construction of those * sources, `fsLookupSources` — mirroring how `fsFloorSources` sits beside * `derivePermissionFloor`. Every caller goes through the factory: the CLI and * audit-ba's RBAC-008 rule. Two hand-rolled constructions is exactly how the * audit ended up running this engine on an EMPTY producer matrix. * * The derivation itself: * * 1. FK candidates = the consumer module entities' OUTGOING relations * (`*→1` / `1→1`), scope ≠ core (core catalogs have platform-gated * `/api/core/{plural}/lookup` endpoints — nothing to grant in the BA). * 2. Producer section resolved deterministically: the producer app's list * screen (SmartListView/SmartCard) bound to the target entity → its * section; else the kebab-plural of the entity among the producer * module's menu sections; else a `needsResolution` finding — NEVER a * guess. * 3. Grantees = consumer actors holding `create`/`update` (any grain) in * the consumer matrix — they need the FK dropdowns of their forms. * 4. Skip rule: an actor already holding the producer SECTION's `read` (or * `lookup`) passes the platform dual gate without a derived grant. * 5. Sensitive producer (the target entity resolves ≥ `confidentiel`, * lib/ba-sensitivity): the candidate is NOT emitted — it is listed under * « ⚠ À valider » and a HUMAN `lookup`/`read` row on the producer * section is the validation (rule 4 then covers it). RBAC-012 audits. * 6. Rows are deduped per (actor × producer section), justification lists * every contributing FK, output is stably sorted → byte-identical * re-runs. */ import { loadEntityContents, normalizeModulePath, parseEntities, type RelationGraph, } from '../../../../lib/ba-relations.js' // lib import ONLY — a cross-skill relative import (create-screen/…) would // break after the installer flattens/renames the skill folders. import { loadScreens, resolveEntitySectionFolder, type ParsedScreen } from '../../../../lib/ba-screens.js' import { loadModuleEntities, type BaEntity } from '../../../../lib/ba-entities.js' import { resolveBaModuleDir } from '../../../../lib/ba-rbac-rows.js' import { formatSensitivity, isSensitive, rankOf, resolveEntitySensitivity, type ResolvedSensitivity, } from '../../../../lib/ba-sensitivity.js' import { holdsProducerSectionGrant, listSections, loadModuleRbacRows, type RbacRow, } from './rbac-rows.js' import type { DeriveLookupReport, LookupGrantRow, PendingSensitiveGrant, SkippedGrant, UnresolvedProducer, } from './types.js' /** Actions whose holders need FK dropdowns in their forms. */ const GRANTEE_ACTIONS = ['create', 'update'] as const /** Section-grain grants that already pass the platform's lookup dual gate. */ const SATISFYING_ACTIONS = ['read', 'lookup'] as const export interface DeriveSources { /** Relation graph parsed from every entité.md (lib/ba-relations). */ graph: RelationGraph /** HUMAN rows of the consumer module's rbac.md (machine block stripped). */ consumerRows: RbacRow[] /** HUMAN rows of a producer module's rbac.md ([] when the file is absent). */ producerRowsOf: (appFolder: string, moduleFolder: string) => RbacRow[] /** Screen registry of an app (derive-related-tabs screens.ts loader). */ screensOf: (appFolder: string) => ParsedScreen[] /** Section folders of a module (lower-kebab menu folders). */ sectionsOf: (appFolder: string, moduleFolder: string) => string[] /** * Entities of a producer module (lib/ba-entities, `[]` when the doc is * absent) — what says whether the producer is ≥ `confidentiel`, in which * case the grant is NOT emitted but listed for a human (RBAC-012). */ entitiesOf: (appFolder: string, moduleFolder: string) => BaEntity[] } /** * THE filesystem-backed `DeriveSources` — shared by the CLI (`index.ts`) and * by audit-ba's RBAC-008 (`rules/rbac.ts`), so the writer and the verifier * can never disagree on what the producers hold. * * Why this exists: RBAC-008 used to rebuild these resolvers from audit-ba's * in-memory corpus, which `loadCorpus` FILTERS BY SCOPE. Under a module-scoped * run `producerRowsOf` returned `[]`, the `holds-read` skip could never fire, * and the rule claimed rows the CLI had rightly skipped — an `err` no remedy * could close (client report Demo-GestionFlotte, 2026-09-04). * * `graph` is injectable on purpose: audit-ba already holds a full-tree graph * and runs PER MODULE — rebuilding it per call would re-parse the whole BA * tree N times. */ export function fsLookupSources( baRoot: string, consumerRows: RbacRow[], graph?: RelationGraph, ): DeriveSources { const screensCache = new Map() const producerRowsCache = new Map() const entitiesCache = new Map() return { graph: graph ?? parseEntities(loadEntityContents(baRoot)), consumerRows, producerRowsOf: (appFolder, moduleFolder) => { const key = `${appFolder}/${moduleFolder}` if (!producerRowsCache.has(key)) { producerRowsCache.set(key, loadModuleRbacRows(baRoot, appFolder, moduleFolder).rows) } return producerRowsCache.get(key)! }, screensOf: (appFolder) => { if (!screensCache.has(appFolder)) { screensCache.set(appFolder, loadScreens(baRoot, appFolder).screens) } return screensCache.get(appFolder)! }, sectionsOf: (appFolder, moduleFolder) => listSections(baRoot, appFolder, moduleFolder), entitiesOf: (appFolder, moduleFolder) => { const key = `${appFolder}/${moduleFolder}` if (!entitiesCache.has(key)) { // Case-insensitive folder resolution, like the rbac rows: the scope // detail of a Relations line is typed by a human. const dir = resolveBaModuleDir(baRoot, appFolder, moduleFolder) entitiesCache.set(key, dir ? (loadModuleEntities(baRoot, dir.app, dir.module)?.entities ?? []) : []) } return entitiesCache.get(key)! }, } } /** The producer's resolved sensitivity — the HIGHEST level among the target entities a candidate path serves. */ function producerSensitivity( sources: DeriveSources, appFolder: string, moduleFolder: string, targetEntities: ReadonlySet, ): ResolvedSensitivity | null { const entities = sources.entitiesOf(appFolder, moduleFolder) let best: ResolvedSensitivity | null = null for (const name of targetEntities) { const e = entities.find((x) => x.name === name) if (!e) continue const r = resolveEntitySensitivity(e, entities) if (best === null || rankOf(r.level) > rankOf(best.level)) best = r } return best } /** BA folder code → nav code: lowercase, `_` → `-` (`HR_PORTAL` → `hr-portal`). */ export function navCodeOf(folder: string): string { return folder.toLowerCase().replace(/_/g, '-') } interface ProducerCandidate { /** App-qualified 4-segment `... .lookup` path. */ path: string appFolder: string moduleFolder: string appCode: string moduleCode: string sectionCode: string justifications: Set /** The producer entities the FKs target (one path may serve several). */ targetEntities: Set } export function deriveLookupGrants( app: string, module: string, sources: DeriveSources, ): Omit { const warnings: string[] = [] const needsResolution: UnresolvedProducer[] = [] const candidates = new Map() const modulePath = normalizeModulePath(`${app}/${module}`) // A Relations entry the grammar could not read is a lookup grant that will // never be derived — carry ba-relations' loss warnings for THIS module. warnings.push(...sources.graph.warnings.filter((w) => normalizeModulePath(w.split(':')[0] ?? '') === modulePath)) const consumerEntities = sources.graph.entities.filter( (e) => normalizeModulePath(e.module) === modulePath, ) let fkCandidates = 0 for (const entity of consumerEntities) { for (const rel of entity.relations) { if (rel.cardinality !== '*→1' && rel.cardinality !== '1→1') continue if (rel.scope === 'core') continue fkCandidates++ let producerAppFolder = app let producerModuleFolder = module if (rel.scope === 'cross-module') { const detail = rel.scopeDetail ?? '' const parts = detail.split('/').map((s) => s.trim()) if (parts.length !== 2 || !parts[0] || !parts[1]) { needsResolution.push({ reason: 'cross-module-scope-detail-missing', consumerEntity: rel.sourceEntity, fk: rel.fk, targetEntity: rel.targetEntity, producerModule: detail || '(scope detail absent)', detail: 'relation cross-module sans détail `(APP/MODULE)` — compléter la ligne Relations dans entité.md', }) continue } producerAppFolder = parts[0] producerModuleFolder = parts[1] } const resolved = resolveProducerSection( sources, producerAppFolder, producerModuleFolder, rel.targetEntity, ) if (!resolved.section) { needsResolution.push({ reason: 'producer-section-unresolved', consumerEntity: rel.sourceEntity, fk: rel.fk, targetEntity: rel.targetEntity, producerModule: `${producerAppFolder}/${producerModuleFolder}`, detail: resolved.detail, }) continue } const appCode = navCodeOf(producerAppFolder) const moduleCode = navCodeOf(producerModuleFolder) const path = `${appCode}.${moduleCode}.${resolved.section}.lookup` const existing = candidates.get(path) const justification = `FK ${rel.sourceEntity}.${rel.fk} → ${rel.targetEntity}` if (existing) { existing.justifications.add(justification) existing.targetEntities.add(rel.targetEntity) } else { candidates.set(path, { path, appFolder: producerAppFolder, moduleFolder: producerModuleFolder, appCode, moduleCode, sectionCode: resolved.section, justifications: new Set([justification]), targetEntities: new Set([rel.targetEntity]), }) } } } // Grantees — consumer actors holding create/update at ANY grain (module, // section or resource rows): their forms carry the FK dropdowns. const grantees = new Map() for (const row of sources.consumerRows) { const action = row.path.split('.').pop() ?? '' if (!(GRANTEE_ACTIONS as readonly string[]).includes(action)) continue if (!grantees.has(row.actorCode) || (!grantees.get(row.actorCode) && row.actorLabel)) { grantees.set(row.actorCode, row.actorLabel) } } const rows: LookupGrantRow[] = [] const skipped: SkippedGrant[] = [] const pendingConfirmation: PendingSensitiveGrant[] = [] const sortedCandidates = [...candidates.values()].sort((a, b) => a.path.localeCompare(b.path)) const sortedGrantees = [...grantees.entries()].sort((a, b) => a[0].localeCompare(b[0])) // One sensitivity resolution per producer path, not per (actor × path). const sensitivityByPath = new Map() for (const cand of sortedCandidates) { sensitivityByPath.set(cand.path, producerSensitivity(sources, cand.appFolder, cand.moduleFolder, cand.targetEntities)) } for (const [actorCode, actorLabel] of sortedGrantees) { for (const cand of sortedCandidates) { // The actor's existing producer grants can be authored in the PRODUCER // matrix (canonical) or historically in the consumer matrix — check both. const isSelf = normalizeModulePath(`${cand.appFolder}/${cand.moduleFolder}`) === modulePath const producerRows = isSelf ? sources.consumerRows : [...sources.producerRowsOf(cand.appFolder, cand.moduleFolder), ...sources.consumerRows] const held = holdsProducerSectionGrant( producerRows, actorCode, cand.appCode, cand.moduleCode, cand.sectionCode, SATISFYING_ACTIONS, ) if (held) { skipped.push({ actorCode, path: cand.path, reason: held === 'lookup' ? 'holds-lookup' : 'holds-read', }) continue } const justification = [...cand.justifications].sort().join(', ') // A sensitive producer (≥ confidentiel, lib/ba-sensitivity): no silent // grant — the candidate waits for a HUMAN row (which the skip above then // reads as « already covered »). RBAC-012 keeps the list visible. const sensitivity = sensitivityByPath.get(cand.path) ?? null if (sensitivity && isSensitive(sensitivity)) { pendingConfirmation.push({ actorCode, actorLabel, path: cand.path, sensitivity: formatSensitivity(sensitivity), justification, }) continue } rows.push({ actorCode, actorLabel, path: cand.path, portee: 'toutes', justification, }) } } return { app, module, rows, skipped, pendingConfirmation, needsResolution, totals: { consumerEntities: consumerEntities.length, fkCandidates, grantees: grantees.size, rows: rows.length, skipped: skipped.length, pendingConfirmation: pendingConfirmation.length, needsResolution: needsResolution.length, }, warnings, } } /** * Deterministic producer-section resolution — THE shared ladder * `lib/ba-screens.resolveEntitySectionFolder` (list screen in the producer * module → kebab-plural section → nothing). audit-ba RBAC-011 / CODE-009 * resolve a sensitive entity's section through the same function, so the * writer and the verifiers can never disagree on which section an entity binds. */ function resolveProducerSection( sources: DeriveSources, appFolder: string, moduleFolder: string, targetEntity: string, ): { section?: string; detail: string } { const resolved = resolveEntitySectionFolder( sources.screensOf(appFolder), sources.sectionsOf(appFolder, moduleFolder), targetEntity, moduleFolder, ) return resolved.section ? resolved : { detail: resolved.detail.replace(`dans le module ${moduleFolder}`, `dans ${appFolder}/${moduleFolder}`) } }