feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) #211

Merged
julien merged 1 commits from feat/ci-catalogue-drift-gate into main 2026-05-24 01:07:14 +02:00
Owner

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:

  • explicit as Privilege / as FunctionalRole casts ('Portal.Foo' as Privilege),
  • the rare case where a developer hand-edits the catalogue type union without updating the runtime constant.

Runs in <1s, so it rides the existing check CI job rather than spinning up its own.

What lands

File Role
scripts/check-catalogue-drift.mjs The gate. TypeScript compiler API. Parses authorization.types.ts to extract the catalogues, walks every .ts file under apps/ and libs/, finds each CallExpression whose callee identifier matches RequirePrivilege / RequireRole, validates every string-literal argument. Non-literal args are skipped (the TypeScript signature catches them already). Reports grouped by file with line:column per violation, exits 1 on drift.
scripts/check-catalogue-drift.spec.mjs 13 tests via node --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.json ci:catalogue-drift + ci:catalogue-drift:test scripts.
.gitea/workflows/ci.yml The existing check job 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-budgets pattern; ~250 lines including tests; runs in the same check CI job that already installs deps. Editor feedback is already provided by tsc, so the gate's job reduces to "catch escape hatches in CI", which the script does fine.

  • Why node --test rather than Vitest / Jest. The script lives in scripts/, 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.ts defines a roles: 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.ts references 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 in does 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 requires slug to be typed FunctionalRole, so the type system handles it; the gate's value-add is on literal misspellings the type system cannot see past an as Privilege cast.

  • Self-test confirmed the gate works. Injected RequirePrivilege('Portal.RogueDrift') and RequireRole('rogue-role-x') into an existing spec, ran pnpm ci:catalogue-drift, observed:

    catalogue-drift: violations found
    
      apps/portal-bff/src/auth/require-privilege.guard.spec.ts
        135:18  @RequirePrivilege('Portal.RogueDrift') — not in PRIVILEGES
        136:13  @RequireRole('rogue-role-x') — not in FUNCTIONAL_ROLES
    

    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_CATALOGUE plus the matching test fixture. The script does not hardcode the decorator name list beyond that map.

  • Why no scope-kind check. @RequireScope takes an (req) => ScopableResource extractor, not literals. The scope kind values are constrained by the ScopeKind discriminated 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 via node --test.
  • pnpm ci:catalogue-drift — clean against the live workspace: catalogue-drift: clean (catalogues: 4 privileges, 24 roles).
  • Self-test by injection — script catches 'Portal.RogueDrift' and 'rogue-role-x' with file:line:column, exits 1.
  • pnpm exec prettier --check on the new files — clean.
  • CI run on this PR exercises the new step in the check job.

What's next

ADR-0025's phasing closes with this PR. Remaining authorization work waits on ADR-0026 (proposed) — the Person + User schema brings the Prisma-backed user_scopes table; that PR replaces StubScopeResolver with a PrismaScopeResolver and unlocks the first concrete @RequireScope consumer surfaces.

## Summary Phase 3 (and last) of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'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: - explicit `as Privilege` / `as FunctionalRole` casts (`'Portal.Foo' as Privilege`), - the rare case where a developer hand-edits the catalogue type union without updating the runtime constant. Runs in `<1s`, so it rides the existing `check` CI job rather than spinning up its own. ## What lands | File | Role | | --- | --- | | `scripts/check-catalogue-drift.mjs` | The gate. TypeScript compiler API. Parses `authorization.types.ts` to extract the catalogues, walks every `.ts` file under `apps/` and `libs/`, finds each `CallExpression` whose callee identifier matches `RequirePrivilege` / `RequireRole`, validates every string-literal argument. Non-literal args are skipped (the TypeScript signature catches them already). Reports grouped by file with `line:column` per violation, exits 1 on drift. | | `scripts/check-catalogue-drift.spec.mjs` | 13 tests via `node --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.json` | `ci:catalogue-drift` + `ci:catalogue-drift:test` scripts. | | `.gitea/workflows/ci.yml` | The existing `check` job 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-budgets` pattern; ~250 lines including tests; runs in the same `check` CI job that already installs deps. Editor feedback is **already provided by `tsc`**, so the gate's job reduces to "catch escape hatches in CI", which the script does fine. - **Why `node --test` rather than Vitest / Jest.** The script lives in `scripts/`, 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.ts` defines a `roles: 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.ts` references 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 in `does 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 requires `slug` to be typed `FunctionalRole`, so the type system handles it; the gate's value-add is on literal misspellings the type system cannot see past an `as Privilege` cast. - **Self-test confirmed the gate works.** Injected `RequirePrivilege('Portal.RogueDrift')` and `RequireRole('rogue-role-x')` into an existing spec, ran `pnpm ci:catalogue-drift`, observed: ``` catalogue-drift: violations found apps/portal-bff/src/auth/require-privilege.guard.spec.ts 135:18 @RequirePrivilege('Portal.RogueDrift') — not in PRIVILEGES 136:13 @RequireRole('rogue-role-x') — not in FUNCTIONAL_ROLES ``` 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_CATALOGUE` plus the matching test fixture. The script does not hardcode the decorator name list beyond that map. - **Why no scope-kind check.** `@RequireScope` takes an `(req) => ScopableResource` extractor, not literals. The scope `kind` values are constrained by the `ScopeKind` discriminated union, which TypeScript enforces at every `{ kind: ... }` construction site. No literal escape hatch worth gating in v1. ## Test plan - [x] `pnpm ci:catalogue-drift:test` — 13/13 green via `node --test`. - [x] `pnpm ci:catalogue-drift` — clean against the live workspace: `catalogue-drift: clean (catalogues: 4 privileges, 24 roles).` - [x] Self-test by injection — script catches `'Portal.RogueDrift'` and `'rogue-role-x'` with file:line:column, exits 1. - [x] `pnpm exec prettier --check` on the new files — clean. - [ ] CI run on this PR exercises the new step in the `check` job. ## What's next ADR-0025's phasing closes with this PR. Remaining authorization work waits on [ADR-0026](docs/decisions/) (proposed) — the `Person` + `User` schema brings the Prisma-backed `user_scopes` table; that PR replaces `StubScopeResolver` with a `PrismaScopeResolver` and unlocks the first concrete `@RequireScope` consumer surfaces.
julien added 1 commit 2026-05-24 01:04:47 +02:00
feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025)
CI / commits (pull_request) Successful in 2m18s
CI / check (pull_request) Successful in 2m57s
CI / scan (pull_request) Successful in 3m5s
CI / a11y (pull_request) Successful in 3m11s
Docs site / build (pull_request) Successful in 3m24s
CI / perf (pull_request) Successful in 6m57s
f9a061e6f8
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).
julien merged commit 2eb01f59b3 into main 2026-05-24 01:07:14 +02:00
julien deleted branch feat/ci-catalogue-drift-gate 2026-05-24 01:07:18 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#211