feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) #211
Reference in New Issue
Block a user
Delete Branch "feat/ci-catalogue-drift-gate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Phase 3 (and last) of ADR-0025's implementation phasing (§"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 decorator signatures (
(...privileges: [Privilege, ...Privilege[]])) already enforce this at compile time. The gate is defence-in-depth against the escape hatches the type system cannot catch:as Privilege/as FunctionalRolecasts ('Portal.Foo' as Privilege),Runs in
<1s, so it rides the existingcheckCI job rather than spinning up its own.What lands
scripts/check-catalogue-drift.mjsauthorization.types.tsto extract the catalogues, walks every.tsfile underapps/andlibs/, finds eachCallExpressionwhose callee identifier matchesRequirePrivilege/RequireRole, validates every string-literal argument. Non-literal args are skipped (the TypeScript signature catches them already). Reports grouped by file withline:columnper violation, exits 1 on drift.scripts/check-catalogue-drift.spec.mjsnode --test(built-in runner, no Vitest dep). Covers catalogue parsing, file-level violation detection, workspace-wide aggregation, skipped folders, gen-stub exclusion, line/column reporting.package.jsonci:catalogue-drift+ci:catalogue-drift:testscripts..gitea/workflows/ci.ymlcheckjob now runs the gate's unit tests first (fail-fast if the gate itself is broken), then the gate against the live workspace.Notes for the reviewer
Why not an ESLint custom rule. ADR-0025 §347 floats either option. The ESLint route would give editor-time feedback (red squiggles) but means standing up a local ESLint plugin lib — net ~300 lines of plumbing for a gate the TypeScript signature already enforces in real time via the literal-union types. The pnpm-script route mirrors the existing
ci:gzip-budgetspattern; ~250 lines including tests; runs in the samecheckCI job that already installs deps. Editor feedback is already provided bytsc, so the gate's job reduces to "catch escape hatches in CI", which the script does fine.Why
node --testrather than Vitest / Jest. The script lives inscripts/, outside any Nx project. Wiring Vitest for one spec file would mean a vitest.config + tsconfig.spec + Nx project just to host it. Node's built-in test runner ships with the runtime we already pin (Node 24 in.nvmrc), no config, ~250ms wall clock for 13 tests.Generated gRPC stubs are skipped.
apps/portal-bff/src/grpc/gen/apf-ai/common.tsdefines aroles: string[]proto field used by the AI-bridge — entirely unrelated to the ADR-0025 functional-role catalogue. The skip list is explicit (SKIPPED_SUBPATHS); adding a new codegen output later is one line.Spec files are NOT skipped.
auth-guards.persona-matrix.spec.tsreferences catalogue values via the decorators in its test fixtures. Those are deliberate references and must stay in sync — if a future ADR amendment removes a role, the spec catches it on the same run as the production code. Explicitly tested indoes NOT skip spec files.Non-literal arguments (variable indirection) are skipped. A pattern like
const slug = getRole(); @RequireRole(slug)would not be caught by string-literal inspection. This is intentional: the TypeScript decorator signature already requiresslugto be typedFunctionalRole, so the type system handles it; the gate's value-add is on literal misspellings the type system cannot see past anas Privilegecast.Self-test confirmed the gate works. Injected
RequirePrivilege('Portal.RogueDrift')andRequireRole('rogue-role-x')into an existing spec, ranpnpm ci:catalogue-drift, observed:Exit code 1. Reverted the injection; gate green again.
Adding a third decorator (per ADR-0025's anticipated growth) is one line in
DECORATOR_TO_CATALOGUEplus the matching test fixture. The script does not hardcode the decorator name list beyond that map.Why no scope-kind check.
@RequireScopetakes an(req) => ScopableResourceextractor, not literals. The scopekindvalues are constrained by theScopeKinddiscriminated union, which TypeScript enforces at every{ kind: ... }construction site. No literal escape hatch worth gating in v1.Test plan
pnpm ci:catalogue-drift:test— 13/13 green vianode --test.pnpm ci:catalogue-drift— clean against the live workspace:catalogue-drift: clean (catalogues: 4 privileges, 24 roles).'Portal.RogueDrift'and'rogue-role-x'with file:line:column, exits 1.pnpm exec prettier --checkon the new files — clean.checkjob.What's next
ADR-0025's phasing closes with this PR. Remaining authorization work waits on ADR-0026 (proposed) — the
Person+Userschema brings the Prisma-backeduser_scopestable; that PR replacesStubScopeResolverwith aPrismaScopeResolverand unlocks the first concrete@RequireScopeconsumer surfaces.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).