/** * RBAC-011 / RBAC-012 — data sensitivity at the section grain * (lib/ba-sensitivity, doctrine 2026-10-03). * RBAC-011 need-to-know: read / read.all / export / lookup on a section * bound to a ≥ confidentiel entity needs a use case of that * section naming the actor (warn), or a 4th cell « décision * client » (silences); an external actor on restreint is an err * RBAC-012 the derived lookup grants a sensitive producer holds for a * HUMAN row (warn) — same engine + sources as RBAC-008, so the * verdict is identical at module / app / project scope */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, 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' const RULES = RBAC_RULES.filter((r) => r.id === 'RBAC-011' || r.id === 'RBAC-012') const entity = (code: string, name: string, cls: string, bullets: string[], attrs: string[], tail: string[] = []): string => [ `### ${code} — ${name} (${cls})`, `- **Préfixe table** : \`${name.slice(0, 3).toLowerCase()}_\``, `- **Description** : ${name}.`, `- **Périmètre** : ${name}.`, ...bullets, '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', ...attrs.map((a) => `| ${a} | string(50) | requis | — |`), '', ...tail, '', ].join('\n') const idx = (title: string): string => `# ${title}\n\n## Contexte\n${title}.\n\n## Hors-périmètre\n_Aucune exclusion connue à ce stade._\n` let dir: string const tmpRoots: string[] = [] function writeCorpus(): string { const root = mkdtempSync(join(tmpdir(), 'ss-rbac-sensitivity-')) tmpRoots.push(root) const w = (rel: string, content: string): void => { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } w('RH/index.md', idx('RH')) w( 'RH/acteur.md', [ '# Acteurs — RH', '', '### BA-001-AC-001 — Gestionnaire', '- **Type** : internal', '', '### BA-001-AC-002 — Partenaire', '- **Type** : external', '', '### BA-001-AC-003 — Lecteur', '- **Type** : internal', '', '### BA-001-AC-004 — Planificateur', '- **Type** : internal', '', ].join('\n'), ) // --- Producer: PAIE — fiches (confidentiel), tarifs (restreint), periodes (interne) w('RH/PAIE/index.md', idx('Paie')) w( 'RH/PAIE/entité.md', [ entity('ENT-001', 'FichePaie', 'agrégat racine', ['- **Sensibilité** : confidentiel — financier'], ['Montant']), entity('ENT-002', 'Tarif', 'agrégat racine', ['- **Sensibilité** : restreint — commercial'], ['Prix']), entity('ENT-003', 'Periode', 'lookup', [], ['Label']), ].join('\n'), ) w('RH/PAIE/fiches/index.md', idx('Fiches')) w('RH/PAIE/tarifs/index.md', idx('Tarifs')) w('RH/PAIE/periodes/index.md', idx('Périodes')) w( 'RH/PAIE/fiches/screen.md', ['### SCR-RH-PAIE-fiches-001 — Fiches de paie (SmartListView)', '- **Entité** : FichePaie (ENT-001)', '- **Permission** : paie.fiches.read', ''].join('\n'), ) w( 'RH/PAIE/fiches/use-case.md', [ '', '# Cas d’usage', '', '### UC-RH-PAIE-FICHES-001 — Consulter une fiche de paie', '- **Acteur principal** : BA-001-AC-001 (Gestionnaire)', '- **Postconditions** : fiche consultée.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — GET renvoie 200.', '', ].join('\n'), ) w( 'RH/PAIE/rbac.md', [ '# RBAC — PAIE', '', '| Acteur | Permission | Portée | Justification |', '|--------|------------|--------|---------------|', '| BA-001-AC-001 (Gestionnaire) | `paie.fiches.access` | toutes | |', '| BA-001-AC-001 (Gestionnaire) | `paie.fiches.read` | toutes | |', '| BA-001-AC-001 (Gestionnaire) | `paie.fiches.create` | toutes | |', '| BA-001-AC-003 (Lecteur) | `paie.fiches.access` | toutes | |', '| BA-001-AC-003 (Lecteur) | `paie.fiches.read` | toutes | |', '| BA-001-AC-003 (Lecteur) | `paie.fiches.export` | toutes | décision client : audit annuel du CFO |', '| BA-001-AC-003 (Lecteur) | `paie.read` | toutes | |', '| BA-001-AC-002 (Partenaire) | `paie.tarifs.read` | les siennes | |', '| BA-001-AC-001 (Gestionnaire) | `paie.periodes.read` | toutes | |', '', ].join('\n'), ) // --- Consumer: PLANNING — FKs onto FichePaie (confidentiel) and Periode (interne) w('RH/PLANNING/index.md', idx('Planning')) w( 'RH/PLANNING/entité.md', entity('ENT-001', 'Affectation', 'agrégat racine', [], ['Libelle'], [ '- **Relations** :', ' - Affectation *→1 FichePaie — FK FichePaieId, scope cross-module (RH/PAIE), onDelete restrict', ' - Affectation *→1 Periode — FK PeriodeId, scope cross-module (RH/PAIE), onDelete restrict', ]), ) w('RH/PLANNING/affectations/index.md', idx('Affectations')) w( 'RH/PLANNING/rbac.md', [ '# RBAC — PLANNING', '', '| Acteur | Permission | Portée |', '|--------|------------|--------|', // AC-001 already reads paie.fiches in the PRODUCER matrix → skipped (holds-read); // AC-004 holds nothing there → the sensitive candidate waits for a human. '| BA-001-AC-001 (Gestionnaire) | `planning.affectations.create` | toutes |', '| BA-001-AC-004 (Planificateur) | `planning.affectations.create` | toutes |', '', ].join('\n'), ) return root } let findings: Finding[] const of = (rule: string, mod: string, from: Finding[] = findings): Finding[] => from.filter((f) => f.ruleId === rule && f.scope.module === mod) beforeAll(() => { dir = writeCorpus() findings = runRules(RULES, loadCorpus(dir), { strict: false }).findings }) afterAll(() => { for (const r of tmpRoots) rmSync(r, { recursive: true, force: true }) }) describe('RBAC-011 — need-to-know on a sensitive section', () => { it('an external actor reading a restreint section → err, whatever the Portée', () => { const ext = of('RBAC-011', 'PAIE').find((f) => f.severity === 'err')! expect(ext).toBeDefined() expect(ext.evidence).toEqual([ 'BA-001-AC-002 (Partenaire) — acteur `external` × `paie.tarifs.read` → section « tarifs » (Tarif, restreint — commercial) : un acteur externe n’accède jamais à une donnée restreinte', ]) }) it('an actor reading a confidentiel section with no use case naming it → warn; the UC-backed actor, the décision-client row and the interne section are silent; a module-grain row covers every sensitive section', () => { const warn = of('RBAC-011', 'PAIE').find((f) => f.severity === 'warn')! expect(warn).toBeDefined() expect(warn.evidence).toHaveLength(2) // fiches: the section row AND the module-grain row, one line. expect(warn.evidence![0]).toContain('BA-001-AC-003 (Lecteur) × `paie.fiches.read`, `paie.read` → section « fiches » (FichePaie, confidentiel — financier)') // tarifs: reached through the module-grain row only. expect(warn.evidence![1]).toContain('BA-001-AC-003 (Lecteur) × `paie.read` → section « tarifs » (Tarif, restreint — commercial)') const all = warn.evidence!.join('\n') expect(all).not.toContain('BA-001-AC-001') expect(all).not.toContain('export') expect(all).not.toContain('periodes') }) it('a module with no sensitive section → ok (vacuously, said)', () => { expect(of('RBAC-011', 'PLANNING').map((f) => [f.severity, f.message])).toEqual([['ok', 'Aucune section du module liée à une entité ≥ confidentiel.']]) }) }) describe('RBAC-012 — derived lookup grants held for a human', () => { it('the consumer whose FK targets a confidentiel producer → warn for the actor holding no read there; the interne producer and the actor already reading are silent', () => { const f = of('RBAC-012', 'PLANNING')[0]! expect(f.severity).toBe('warn') expect(f.evidence).toEqual([ 'BA-001-AC-004 (Planificateur) → `rh.paie.fiches.lookup` (confidentiel — financier) : FK Affectation.FichePaieId → FichePaie', ]) }) it('the producer module itself (no outgoing FK) → ok', () => { expect(of('RBAC-012', 'PAIE').map((f) => f.severity)).toEqual(['ok']) }) it('renders the SAME verdict at module, app and project scope (the RBAC-008 invariant)', () => { const reference = of('RBAC-012', 'PLANNING')[0]! for (const scope of [{ app: 'RH', module: 'PLANNING' }, { app: 'RH' }]) { const scoped = runRules(RULES, loadCorpus(dir, scope), { strict: false }).findings const f = of('RBAC-012', 'PLANNING', scoped)[0]! expect({ sev: f.severity, ev: f.evidence ?? [] }).toEqual({ sev: reference.severity, ev: reference.evidence ?? [] }) } }) })