/** * 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. 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' import { pluralize, toKebabCase } from '../../../../lib/string-utils.js' // lib import ONLY — a cross-skill relative import (create-screen/…) would // break after the installer flattens/renames the skill folders. import { loadScreens, resolveListTarget, type ParsedScreen } from '../../../../lib/ba-screens.js' import { holdsProducerSectionGrant, listSections, loadModuleRbacRows, type RbacRow, } from './rbac-rows.js' import type { DeriveLookupReport, LookupGrantRow, 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[] } /** * 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() 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), } } /** 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 } 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) } else { candidates.set(path, { path, appFolder: producerAppFolder, moduleFolder: producerModuleFolder, appCode, moduleCode, sectionCode: resolved.section, justifications: new Set([justification]), }) } } } // 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 sortedCandidates = [...candidates.values()].sort((a, b) => a.path.localeCompare(b.path)) const sortedGrantees = [...grantees.entries()].sort((a, b) => a[0].localeCompare(b[0])) 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 } rows.push({ actorCode, actorLabel, path: cand.path, portee: 'toutes', justification: [...cand.justifications].sort().join(', '), }) } } return { app, module, rows, skipped, needsResolution, totals: { consumerEntities: consumerEntities.length, fkCandidates, grantees: grantees.size, rows: rows.length, skipped: skipped.length, needsResolution: needsResolution.length, }, warnings, } } /** * Deterministic producer-section resolution ladder: * 1. the producer app's list screen bound to the entity, IF it lives in the * producer module (its section folder wins); * 2. the kebab-plural of the entity among the producer module's sections; * 3. nothing — the caller records a `needsResolution` finding. */ function resolveProducerSection( sources: DeriveSources, appFolder: string, moduleFolder: string, targetEntity: string, ): { section?: string; detail: string } { const screens = sources.screensOf(appFolder) const hit = resolveListTarget(screens, targetEntity, moduleFolder) if (hit.resolved && hit.screen && hit.screen.module.toUpperCase() === moduleFolder.toUpperCase()) { const sectionCode = hit.screen.section.split('/')[0] if (sectionCode) return { section: sectionCode, detail: `écran liste ${hit.code}` } } const sections = sources.sectionsOf(appFolder, moduleFolder) const candidate = toKebabCase(pluralize(targetEntity)) if (sections.includes(candidate)) { return { section: candidate, detail: `section menu « ${candidate} »` } } return { detail: `aucun écran liste (SmartListView/SmartCard) lié à ${targetEntity} dans ` + `${appFolder}/${moduleFolder}, et aucune section « ${candidate} » au menu`, } }