From f9a061e6f8a4fe2cdbf59e0966c57f462e3385e8 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 24 May 2026 00:59:57 +0200 Subject: [PATCH] feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the ADR-0025 phasing per its §"More Information" §347: a small CI gate that asserts every string literal passed to the authorization decorators belongs to the closed catalogue declared in libs/shared/auth/src/lib/authorization.types.ts. The TypeScript signature on the decorators already enforces this at compile time — the new gate is defence-in-depth against escape hatches (`as Privilege` casts, hand-edits to the type union without updating the runtime constant). It runs in <1s, so it rides the existing `check` CI job rather than starting its own. Implementation: - scripts/check-catalogue-drift.mjs: TypeScript compiler API. Parses authorization.types.ts to extract the catalogue arrays, walks every .ts file under apps/ and libs/, finds each CallExpression whose callee identifier matches @RequirePrivilege or @RequireRole, validates every string-literal argument. Non-literal args (variable indirection) are skipped on purpose — the TypeScript signature catches them at compile time and the gate's value-add is on literal misspellings. Generated gRPC stubs under apps/portal-bff/src/grpc/gen are skipped (their `roles[]` field is a wire-format unrelated to the catalogue). Reports grouped by file with line:column for each violation, exits 1 on drift. - scripts/check-catalogue-drift.spec.mjs: 13 tests via node:test (built-in, no Vitest dependency). Covers catalogue parsing, file-level violation detection, workspace-wide aggregation, skipped folders, gen-stub exclusion, line/column reporting. - package.json: ci:catalogue-drift + ci:catalogue-drift:test scripts. - .gitea/workflows/ci.yml: the `check` job now runs the gate's unit tests (first, to fail fast if the gate itself is broken) then the gate against the live workspace. Self-tested by injecting `RequirePrivilege('Portal.RogueDrift')` and `RequireRole('rogue-role-x')` into an existing spec — both caught with file:line:column and exit code 1. Test plan: - pnpm ci:catalogue-drift:test: 13/13 green. - pnpm ci:catalogue-drift: clean (4 privileges, 24 roles). - pnpm nx affected -t format:check lint test build: no project affected (script lives outside Nx). --- .gitea/workflows/ci.yml | 9 + package.json | 2 + scripts/check-catalogue-drift.mjs | 273 +++++++++++++++++++++++++ scripts/check-catalogue-drift.spec.mjs | 233 +++++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 scripts/check-catalogue-drift.mjs create mode 100644 scripts/check-catalogue-drift.spec.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7123a19..3b09f26 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,6 +44,15 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm ci:check + # Catalogue-vs-code drift gate per ADR-0025 §"Confirmation". + # Cheap (parses ~workspace .ts files via the TypeScript + # compiler API, ~1s on a warm cache) so it rides the same + # `check` job rather than spinning up a new one. The + # script's own unit tests run first — if they fail the gate + # itself is broken and the actual catalogue check would be + # noise. + - run: pnpm ci:catalogue-drift:test + - run: pnpm ci:catalogue-drift scan: runs-on: [self-hosted, on-prem] diff --git a/package.json b/package.json index 3196ab0..81f301d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "prepare": "husky", "ci:check": "pnpm exec nx affected -t format:check lint test build", "ci:audit": "pnpm audit --audit-level=moderate", + "ci:catalogue-drift": "node scripts/check-catalogue-drift.mjs", + "ci:catalogue-drift:test": "node --test scripts/check-catalogue-drift.spec.mjs", "ci:commits": "pnpm exec commitlint --from ${COMMIT_LINT_FROM:-origin/main} --to HEAD --verbose", "ci:gzip-budgets": "node scripts/check-gzip-budgets.mjs", "ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm ci:gzip-budgets && pnpm exec lhci autorun --config=./lighthouserc.js", diff --git a/scripts/check-catalogue-drift.mjs b/scripts/check-catalogue-drift.mjs new file mode 100644 index 0000000..23e32c5 --- /dev/null +++ b/scripts/check-catalogue-drift.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * Catalogue-vs-code drift gate per + * [ADR-0025 §"Confirmation"](../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). + * + * Asserts that every string literal passed to the authorization + * decorators in the codebase belongs to the closed catalogue + * declared in `libs/shared/auth/src/lib/authorization.types.ts`: + * + * - `@RequirePrivilege('Portal.X', …)` → every arg ∈ PRIVILEGES + * - `@RequireRole('rh', …)` → every arg ∈ FUNCTIONAL_ROLES + * + * The TypeScript type system enforces the same constraint at + * compile time — the decorator signatures are typed against the + * `Privilege` / `FunctionalRole` literal unions — so the gate is + * defence-in-depth against escape hatches: + * + * - explicit `as Privilege` casts (`'Portal.Foo' as Privilege`), + * - indirection through a `string`-typed local + * (`const slug = 'rh'; @RequireRole(slug)` — non-literal + * args are skipped here, but the gate at least catches the + * literal misspellings), + * - and the rarer case where a developer hand-edits the + * catalogue type union without updating the runtime constant. + * + * Implementation: the script parses the catalogue file and every + * `.ts` file under `apps/` and `libs/` with the TypeScript compiler + * API. For each `CallExpression` whose callee identifier matches + * one of the decorator names, every string-literal argument is + * checked against the catalogue. Non-literal arguments are + * skipped silently; the gate is best-effort for those, but they + * are caught by the TypeScript signature anyway. + * + * Exit code: 0 if no drift, 1 with a grouped report if drift is + * found. The script does not consume any env vars; running it + * directly (`node scripts/check-catalogue-drift.mjs`) and via + * `pnpm ci:catalogue-drift` are equivalent. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const WORKSPACE_ROOT = resolve(__dirname, '..'); + +const CATALOGUE_PATH = join(WORKSPACE_ROOT, 'libs/shared/auth/src/lib/authorization.types.ts'); + +/** + * Decorator name → catalogue array name. Adding a third decorator + * (per ADR-0025's anticipated growth) is one line here plus the + * matching test fixture. + */ +const DECORATOR_TO_CATALOGUE = { + RequirePrivilege: 'PRIVILEGES', + RequireRole: 'FUNCTIONAL_ROLES', +}; + +/** + * Folders to skip when walking the workspace. Stays small on + * purpose: every other path under `apps/` and `libs/` is fair + * game for catalogue references, including specs (the persona + * matrix in `auth-guards.persona-matrix.spec.ts` is a deliberate + * usage and must stay in sync). + */ +const SKIPPED_DIRS = new Set([ + 'node_modules', + 'dist', + 'coverage', + '.nx', + '.angular', + '__screenshots__', +]); + +/** + * Files / subpaths to skip outright. The generated gRPC stubs + * under `apps/portal-bff/src/grpc/gen/` carry their own + * `roles[]` field that has nothing to do with the ADR-0025 + * functional-role catalogue — skipping the whole dir keeps the + * scanner focused on hand-written code. + */ +const SKIPPED_SUBPATHS = ['apps/portal-bff/src/grpc/gen']; + +/** + * Parse `authorization.types.ts` and extract the catalogue + * arrays as `Set` keyed on the constant's name. + * Returns `{ PRIVILEGES: Set, FUNCTIONAL_ROLES: Set }`. + * + * Exported for the spec — keeps the parser logic testable without + * spawning the CLI. + */ +export function extractCatalogues(sourceFilePath = CATALOGUE_PATH) { + const text = readFileSync(sourceFilePath, 'utf8'); + const sourceFile = ts.createSourceFile(sourceFilePath, text, ts.ScriptTarget.Latest, true); + const out = {}; + + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; + const name = decl.name.text; + if (!Object.values(DECORATOR_TO_CATALOGUE).includes(name)) continue; + + // Catalogues are declared as `[...] as const`; strip the + // type assertion to reach the array literal. + let init = decl.initializer; + if (ts.isAsExpression(init)) init = init.expression; + if (!ts.isArrayLiteralExpression(init)) continue; + + const values = new Set(); + for (const el of init.elements) { + if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) { + values.add(el.text); + } + } + out[name] = values; + } + } + + for (const expected of Object.values(DECORATOR_TO_CATALOGUE)) { + if (!out[expected]) { + throw new Error( + `Failed to extract catalogue "${expected}" from ${sourceFilePath} — ` + + `the script expected a top-level "export const ${expected} = [...] as const" declaration.`, + ); + } + } + + return out; +} + +/** + * Walk `dir` recursively and yield every `.ts` file path + * (excluding declaration files and the dirs listed in + * SKIPPED_DIRS / SKIPPED_SUBPATHS). + */ +function* walkTsFiles(dir, rootForRelative) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + if (SKIPPED_DIRS.has(entry) || entry.startsWith('.')) continue; + const full = join(dir, entry); + const rel = relative(rootForRelative, full).replaceAll('\\', '/'); + if (SKIPPED_SUBPATHS.some((skip) => rel === skip || rel.startsWith(skip + '/'))) { + continue; + } + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + yield* walkTsFiles(full, rootForRelative); + } else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) { + yield full; + } + } +} + +/** + * Find every catalogue-violation in a single source file. + * Returns an array of `{ file, line, column, callee, value, + * catalogue }` records. Exported for the spec. + */ +export function findViolationsInFile(filePath, catalogues, sourceText) { + const text = sourceText ?? readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true); + const violations = []; + + function visit(node) { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const callee = node.expression.text; + const catalogueName = DECORATOR_TO_CATALOGUE[callee]; + if (catalogueName) { + const valid = catalogues[catalogueName]; + for (const arg of node.arguments) { + if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) { + if (!valid.has(arg.text)) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(arg.getStart()); + violations.push({ + file: filePath, + line: line + 1, + column: character + 1, + callee, + value: arg.text, + catalogue: catalogueName, + }); + } + } + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return violations; +} + +/** + * Top-level scan: walk `apps/` and `libs/`, accumulate + * violations, return them all. Pure (no I/O outside the file + * reads it does itself). Exported for the spec. + */ +export function scanWorkspace(workspaceRoot = WORKSPACE_ROOT) { + const catalogues = extractCatalogues( + join(workspaceRoot, 'libs/shared/auth/src/lib/authorization.types.ts'), + ); + const out = []; + for (const dirName of ['apps', 'libs']) { + const dir = join(workspaceRoot, dirName); + for (const file of walkTsFiles(dir, workspaceRoot)) { + out.push(...findViolationsInFile(file, catalogues)); + } + } + return { violations: out, catalogues }; +} + +function main() { + const { violations, catalogues } = scanWorkspace(); + + if (violations.length === 0) { + const privCount = catalogues.PRIVILEGES.size; + const roleCount = catalogues.FUNCTIONAL_ROLES.size; + console.log( + `catalogue-drift: clean (catalogues: ${privCount} privileges, ${roleCount} roles).`, + ); + process.exit(0); + } + + console.error('catalogue-drift: violations found'); + console.error(''); + // Group by file for a readable report. Within a file, sort by + // line so the output mirrors the editor's view. + const byFile = new Map(); + for (const v of violations) { + if (!byFile.has(v.file)) byFile.set(v.file, []); + byFile.get(v.file).push(v); + } + for (const [file, list] of byFile) { + list.sort((a, b) => a.line - b.line || a.column - b.column); + const rel = relative(WORKSPACE_ROOT, file).replaceAll('\\', '/'); + console.error(` ${rel}`); + for (const v of list) { + console.error( + ` ${v.line}:${v.column} @${v.callee}('${v.value}') — not in ${v.catalogue}`, + ); + } + } + console.error(''); + console.error( + `Catalogues are closed in v1 per ADR-0025. To add a value, amend ` + + `the ADR and the corresponding constant in ` + + `libs/shared/auth/src/lib/authorization.types.ts.`, + ); + process.exit(1); +} + +// Only run main() when invoked as a CLI (not when imported by the +// spec). +const isMain = + import.meta.url === `file://${process.argv[1]}` || + import.meta.url === `file://${resolve(process.argv[1])}`; +if (isMain) { + main(); +} diff --git a/scripts/check-catalogue-drift.spec.mjs b/scripts/check-catalogue-drift.spec.mjs new file mode 100644 index 0000000..e3452a5 --- /dev/null +++ b/scripts/check-catalogue-drift.spec.mjs @@ -0,0 +1,233 @@ +/** + * Tests for `check-catalogue-drift.mjs`. Run via: + * node --test scripts/check-catalogue-drift.spec.mjs + * + * Uses Node's built-in test runner (no Vitest / Jest dependency) + * because the script itself has no per-app project hosting it; + * shipping a third test runner just for this one file would be + * overkill. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + extractCatalogues, + findViolationsInFile, + scanWorkspace, +} from './check-catalogue-drift.mjs'; + +/** + * Build a self-contained workspace fixture with: + * - a synthesised authorization.types.ts file mirroring the real + * catalogue declarations, + * - a bag of .ts files under apps/ + libs/ exercising the + * decorators with whichever values the caller asks for. + */ +function makeFixture({ files }) { + const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); + mkdirSync(join(root, 'libs/shared/auth/src/lib'), { recursive: true }); + writeFileSync( + join(root, 'libs/shared/auth/src/lib/authorization.types.ts'), + `export const PRIVILEGES = [ + 'Portal.Admin', + 'Portal.Auditor', + ] as const; + export const FUNCTIONAL_ROLES = [ + 'collaborateur', + 'rh', + 'dpo', + ] as const; + export const SCOPE_KINDS = [ + 'self', + 'unrestricted', + ] as const; + `, + ); + for (const [relativePath, contents] of Object.entries(files)) { + const full = join(root, relativePath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, contents); + } + return root; +} + +describe('extractCatalogues', () => { + it('reads the three closed catalogues from authorization.types.ts', () => { + const root = makeFixture({ files: {} }); + const cataloguePath = join(root, 'libs/shared/auth/src/lib/authorization.types.ts'); + const cats = extractCatalogues(cataloguePath); + assert.deepEqual([...cats.PRIVILEGES].sort(), ['Portal.Admin', 'Portal.Auditor']); + assert.deepEqual([...cats.FUNCTIONAL_ROLES].sort(), ['collaborateur', 'dpo', 'rh']); + }); + + it('throws when the catalogue file omits an expected declaration', () => { + const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); + mkdirSync(join(root, 'libs/shared/auth/src/lib'), { recursive: true }); + writeFileSync( + join(root, 'libs/shared/auth/src/lib/authorization.types.ts'), + `export const PRIVILEGES = ['Portal.Admin'] as const;\n`, + ); + assert.throws( + () => extractCatalogues(join(root, 'libs/shared/auth/src/lib/authorization.types.ts')), + /FUNCTIONAL_ROLES/, + ); + }); +}); + +describe('findViolationsInFile', () => { + const catalogues = { + PRIVILEGES: new Set(['Portal.Admin', 'Portal.Auditor']), + FUNCTIONAL_ROLES: new Set(['collaborateur', 'rh', 'dpo']), + }; + + it('returns no violations on a file that uses only catalogue values', () => { + const text = ` + class Foo { + @RequirePrivilege('Portal.Admin') + @RequireRole('rh', 'collaborateur') + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 0); + }); + + it('flags every off-catalogue privilege value', () => { + const text = ` + class Foo { + @RequirePrivilege('Portal.Admin', 'Portal.Bogus') + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].callee, 'RequirePrivilege'); + assert.equal(violations[0].value, 'Portal.Bogus'); + assert.equal(violations[0].catalogue, 'PRIVILEGES'); + }); + + it('flags every off-catalogue role value', () => { + const text = ` + class Foo { + @RequireRole('rh', 'rogue-role') + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].callee, 'RequireRole'); + assert.equal(violations[0].value, 'rogue-role'); + assert.equal(violations[0].catalogue, 'FUNCTIONAL_ROLES'); + }); + + it('skips non-literal arguments (variable indirection)', () => { + // A real-world escape hatch — TypeScript catches this at + // compile time via the decorator signature, the drift gate is + // best-effort here. + const text = ` + const slug = 'rogue-role'; + class Foo { + @RequireRole(slug) + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 0); + }); + + it('also detects the literal when the decorator is invoked as a function call (non-decorator usage)', () => { + // A test fixture might call RequirePrivilege as a function to + // produce a decorator value for a manual UseGuards pattern; the + // call site shape is identical so the script catches it. + const text = ` + const dec = RequirePrivilege('Portal.Bogus'); + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].value, 'Portal.Bogus'); + }); + + it('reports line and column of the offending literal', () => { + const text = ['line1', 'line2', " @RequireRole('rogue-role')"].join('\n'); + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].line, 3); + // The literal starts at column 16 (0-indexed 15, +1 in the report). + assert.equal(violations[0].column, 16); + }); +}); + +describe('scanWorkspace', () => { + it('returns 0 violations on a clean fixture', () => { + const root = makeFixture({ + files: { + 'apps/x/src/main.ts': ` + class Foo { + @RequirePrivilege('Portal.Admin') + @RequireRole('rh', 'collaborateur') + m() {} + } + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 0); + }); + + it('aggregates violations across multiple files', () => { + const root = makeFixture({ + files: { + 'apps/x/src/a.ts': ` + @RequirePrivilege('Portal.Ghost') class A {} + `, + 'libs/y/src/b.ts': ` + @RequireRole('phantom-role') class B {} + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 2); + const values = violations.map((v) => v.value).sort(); + assert.deepEqual(values, ['Portal.Ghost', 'phantom-role']); + }); + + it('skips dist/ and node_modules/ folders even when they contain decorator calls', () => { + const root = makeFixture({ + files: { + 'apps/x/dist/built.ts': `@RequireRole('rogue-role') class X {}`, + 'apps/x/node_modules/whatever/foo.ts': `@RequireRole('also-rogue') class Y {}`, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 0); + }); + + it('skips the generated gRPC stubs (different `roles` semantics)', () => { + const root = makeFixture({ + files: { + 'apps/portal-bff/src/grpc/gen/apf-ai/common.ts': ` + // Codegen output: this is NOT an ADR-0025 decorator call, + // it just happens to share an identifier. + @RequireRole('not-a-real-role') class FromGen {} + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 0); + }); + + it('does NOT skip spec files (deliberate decorator usage in tests must stay in sync)', () => { + const root = makeFixture({ + files: { + 'libs/y/src/y.spec.ts': ` + @RequireRole('rogue-role') class TestSubject {} + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 1); + }); +}); -- 2.30.2