/** * RBAC-008 under a SCOPED audit run — the Demo-GestionFlotte regression. * * The rule imports the create-rbac derivation engine. It used to rebuild that * engine's `DeriveSources` from audit-ba's in-memory corpus, which * `loadCorpus` FILTERS BY SCOPE: under `{app, module}` the producer modules * were absent, `producerRowsOf` returned `[]`, the engine's `holds-read` skip * could never fire, and RBAC-008 demanded grants `derive-lookup-grants` had * rightly skipped — an `err` that `/audit-fix` re-ran forever with a nil delta * (`filesModified: []`, `delta.persisting`). * * The scope is what made it invisible: a PROJECT-wide run loads every module * and both paths agree. The scoped run is the NOMINAL one (`/ba-audit-rbac` * per module, `/audit-fix APP/MODULE`). */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { loadCorpus } from '../corpus/model.js' import { runRules } from '../engine.js' import { RBAC_RULES } from '../rules/rbac.js' import type { Finding } from '../types.js' import { deriveLookupGrants, fsLookupSources, } from '../../../../create-rbac/cli/derive-lookup-grants/derive.js' import { checkLookupDrift } from '../../../../create-rbac/cli/derive-lookup-grants/block.js' import { parseRbacRows } from '../../../../../lib/ba-rbac-rows.js' const RULE = RBAC_RULES.filter((r) => r.id === 'RBAC-008') const tmpRoots: string[] = [] /** * LOG/EXPEDITIONS (consumer, FK -> Vehicle) + LOG/PARC (producer, list screen). * `producerHoldsRead` toggles the ONE row the whole regression turns on. */ function writeCorpus(producerHoldsRead: boolean): string { const root = mkdtempSync(join(tmpdir(), 'ss-rbac008-')) tmpRoots.push(root) const w = (rel: string, content: string): void => { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } const idx = (title: string): string => `# ${title}\n\n## Contexte\n${title}.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n` w('LOG/index.md', idx('Logistique')) w('LOG/acteur.md', '# Acteurs — LOG\n\n### BA-001-AC-001 — Gestionnaire\n- **Type** : internal\n') // --- Producer: PARC / vehicules ----------------------------------------- w('LOG/PARC/index.md', idx('Parc')) w( 'LOG/PARC/entité.md', [ '### ENT-001 — Vehicle (agrégat racine)', '- **Préfixe table** : `parc_`', '- **Traçabilité** : UC-LOG-PARC-vehicules-001', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(120) | requis | — |', '', ].join('\n'), ) w( 'LOG/PARC/rbac.md', [ '# RBAC — PARC', '', '| Acteur | Permission | Portée | Justification |', '|--------|------------|--------|---------------|', '| BA-001-AC-001 (Gestionnaire) | `parc.vehicules.access` | toutes | accès |', // THE row. Section grain, in the PRODUCER matrix — where the canonical // grant lives, and precisely what a scope-filtered corpus hid. ...(producerHoldsRead ? ['| BA-001-AC-001 (Gestionnaire) | `parc.vehicules.read` | toutes | lecture |'] : []), '', ].join('\n'), ) w( 'LOG/PARC/vehicules/screen.md', [ '### SCR-LOG-PARC-vehicules-001 — Liste des véhicules (SmartListView)', '- **Entité** : Vehicle (ENT-001)', '- **Permission** : parc.vehicules.read', '', ].join('\n'), ) // --- Consumer: EXPEDITIONS / courses, FK -> Vehicle ---------------------- w('LOG/EXPEDITIONS/index.md', idx('Expéditions')) w( 'LOG/EXPEDITIONS/entité.md', [ '### ENT-001 — Shipment (agrégat racine)', '- **Préfixe table** : `exp_`', '- **Traçabilité** : UC-LOG-EXPEDITIONS-courses-001', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Reference | string(50) | requis | — |', '| VehicleId | Guid | requis | — |', '', '- **Relations** : Shipment *→1 Vehicle — FK VehicleId, scope cross-module (LOG/PARC), onDelete restrict.', '', ].join('\n'), ) w( 'LOG/EXPEDITIONS/rbac.md', [ '# RBAC — EXPEDITIONS', '', '| Acteur | Permission | Portée | Justification |', '|--------|------------|--------|---------------|', '| BA-001-AC-001 (Gestionnaire) | `expeditions.courses.access` | toutes | accès |', '| BA-001-AC-001 (Gestionnaire) | `expeditions.courses.read` | toutes | lecture |', '| BA-001-AC-001 (Gestionnaire) | `expeditions.courses.create` | toutes | saisie |', '', // The machine block as the CLI leaves it when every candidate is skipped: // no data row. Only actor row lines are drift units. '', '### Grants `lookup` dérivés — bloc machine (ne pas éditer à la main)', '', '_Aucun grant `lookup` dérivé._', '', '', ].join('\n'), ) w( 'LOG/EXPEDITIONS/courses/screen.md', [ '### SCR-LOG-EXPEDITIONS-courses-001 — Liste des courses (SmartListView)', '- **Entité** : Shipment (ENT-001)', '- **Permission** : expeditions.courses.read', '', ].join('\n'), ) return root } /** RBAC-008 finding for LOG/EXPEDITIONS at a given corpus scope. */ function rbac008(root: string, scope: { app?: string; module?: string }): Finding { const { findings } = runRules(RULE, loadCorpus(root, scope), { strict: false }) const hit = findings.find((f) => f.scope.module === 'EXPEDITIONS') expect(hit, `no RBAC-008 finding for EXPEDITIONS at scope ${JSON.stringify(scope)}`).toBeDefined() return hit! } const SCOPES: Array<[string, { app?: string; module?: string }]> = [ ['module', { app: 'LOG', module: 'EXPEDITIONS' }], ['app', { app: 'LOG' }], ['project', {}], ] afterAll(() => { for (const r of tmpRoots) rmSync(r, { recursive: true, force: true }) }) describe('RBAC-008 — the holds-read skip survives a scoped run', () => { it('is ok at EVERY scope when the producer matrix grants the section read', () => { const root = writeCorpus(true) for (const [label, scope] of SCOPES) { const f = rbac008(root, scope) expect(f.severity, `scope ${label} — evidence: ${JSON.stringify(f.evidence)}`).toBe('ok') } }) it('still errs when the producer grants NOTHING — the fix must not blind the rule', () => { const root = writeCorpus(false) for (const [label, scope] of SCOPES) { const f = rbac008(root, scope) expect(f.severity, `scope ${label}`).toBe('err') expect(f.evidence?.join('\n'), `scope ${label}`).toContain('log.parc.vehicules.lookup') } }) it('renders the SAME verdict at module, app and project scope (both corpora)', () => { // The invariant lib/remediation.ts promises for every REMEDY_REGISTRY row: // the engine backing the rule is the one the remedy runs. A scope-dependent // verdict is that invariant broken. for (const held of [true, false]) { const root = writeCorpus(held) const reference = rbac008(root, {}) for (const [label, scope] of SCOPES) { const f = rbac008(root, scope) expect({ label, sev: f.severity, ev: f.evidence ?? [] }).toEqual({ label, sev: reference.severity, ev: reference.evidence ?? [], }) } } }) }) describe('RBAC-008 — the rule and the CLI compute the same drift', () => { it('audit-ba (scoped) agrees with derive-lookup-grants --mode check (disk)', () => { for (const held of [true, false]) { const root = writeCorpus(held) const consumerPath = join(root, 'LOG', 'EXPEDITIONS', 'rbac.md') const content = readFileSync(consumerPath, 'utf8') // The CLI path: filesystem sources, no corpus at all. const core = deriveLookupGrants( 'LOG', 'EXPEDITIONS', fsLookupSources(root, parseRbacRows(content)), ) const drift = checkLookupDrift(content, core) // The rule path, at the NARROWEST scope. const f = rbac008(root, { app: 'LOG', module: 'EXPEDITIONS' }) expect(drift.upToDate).toBe(held) expect(f.severity).toBe(drift.upToDate ? 'ok' : 'err') expect(core.skipped.length).toBe(held ? 1 : 0) expect(core.rows.length).toBe(held ? 0 : 1) } }) })