Files
julien 2eb01f59b3
CI / check (push) Successful in 3m4s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m53s
CI / a11y (push) Successful in 3m50s
CI / perf (push) Successful in 5m23s
Docs site / build (push) Successful in 5m26s
feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) (#211)
## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #211
2026-05-24 01:07:13 +02:00

224 lines
9.6 KiB
YAML

# Per ADR-0015 (CI/CD on Gitea Actions).
# Thin YAML — orchestration lives in package.json scripts (ci:check,
# ci:audit, ci:commits, ci:perf) and Nx targets. Any change to gate
# behaviour belongs in those scripts, not in this file.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Derive NX_BASE / NX_HEAD for `nx affected`. Replaces
# nrwl/nx-set-shas@v4, which is GitHub-only (it queries the GitHub
# API to find the last successful workflow run, returning 404 on
# Gitea). HEAD~1 is a reasonable approximation for push events on
# a squash-merge trunk; pull_request uses the merge-base with the
# target branch.
- name: Derive Nx affected base and head
shell: bash
run: |
# `actions/checkout@v4` with fetch-depth: 0 already pulls every
# branch and tag, so origin/<base_ref> is present locally — no
# extra `git fetch` is needed (and `--depth=0` is invalid: git
# requires a positive integer).
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "NX_BASE=$(git merge-base HEAD origin/${{ github.base_ref }})" >> "$GITHUB_ENV"
else
echo "NX_BASE=HEAD~1" >> "$GITHUB_ENV"
fi
echo "NX_HEAD=HEAD" >> "$GITHUB_ENV"
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
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]
# Step ordering matters here: Trivy and gitleaks BOTH run before
# `pnpm install`. Reason: gitleaks scans the working tree
# (`--no-git --source .`), and after install, `node_modules/`
# and `.pnpm-store/` are full of upstream packages whose READMEs
# and test fixtures contain demo RSA keys / fake API tokens —
# gitleaks then false-positives on them by the hundreds (caught
# the hard way: 381 hits on the first run). Trivy reads
# `pnpm-lock.yaml` for its vuln scan, not `node_modules`, so it
# also doesn't need install. `pnpm ci:audit` does the same — it
# queries the advisory DB against the lockfile.
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
# Dependency vulnerability scan. Trivy is a Go binary, not an npm
# package, so it cannot live in package.json scripts as cleanly
# as audit/lint do.
#
# We deliberately avoid `aquasecurity/trivy-action`. On cache
# miss the action falls back to `git clone github.com/aquasecurity/
# trivy` to fetch its install script, using `actions/checkout`
# which defaults `with.token` to `${{ github.token }}` (Gitea's
# auto-token, useless for github.com). The clone hits the
# anonymous github.com rate limit and fails with "could not
# read Username". Passing GITHUB_TOKEN as an env var doesn't
# help — actions/checkout reads it from `inputs.token`, not env.
#
# Direct curl + tar is simpler, predictable, and gives us an
# explicit version pin instead of `@master`. GITHUBCOM_TOKEN is
# passed to handle the github.com rate limit on the release
# download in the worst case (release artefacts are usually
# unmetered, but auth is free insurance).
- name: Install Trivy
env:
# renovate: datasource=github-releases depName=aquasecurity/trivy
TRIVY_VERSION: '0.70.0'
GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }}
run: |
curl -sfL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-o /tmp/trivy.tar.gz \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy
trivy --version
- name: Run Trivy
# `--scanners vuln`: limit Trivy to vulnerability scanning. Its
# secret scanner false-positives on demo RSA keys embedded in
# the README/fixtures of cryptographic npm packages (which
# land under .pnpm-store/), and we already have gitleaks below
# as the dedicated secret-scan gate. Trivy's intent in this
# job, per ADR-0015, was always "dependency vulnerability
# scan" — restoring that scope.
run: |
trivy fs \
--scanners vuln \
--ignore-unfixed \
--skip-dirs node_modules \
--exit-code 1 \
--severity CRITICAL,HIGH \
.
# Secret scan. Same install pattern as Trivy: gitleaks is a Go
# binary, and the official `gitleaks/gitleaks-action@v2` wrapper
# is now paywalled for organisations (a GITLEAKS_LICENSE secret
# from gitleaks.io is required, otherwise the action errors out
# with `🛑 missing gitleaks license`). The binary itself stays
# MIT-licensed and free — installing it directly bypasses the
# wrapper and gives us version pinning for free.
- name: Install gitleaks
env:
# renovate: datasource=github-releases depName=gitleaks/gitleaks
GITLEAKS_VERSION: '8.30.1'
GITHUB_TOKEN: ${{ secrets.GITHUBCOM_TOKEN }}
run: |
curl -sfL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-o /tmp/gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks
gitleaks version
- name: Run gitleaks
# `--no-git --source .` scans the working tree only. The scan
# job uses a shallow checkout, so a git-history scan would not
# see beyond HEAD anyway; the weekly security-scheduled
# workflow does the deep history scan with a full clone.
# `--redact` masks any matched secret in the log output so we
# do not leak it via the CI logs themselves.
run: |
gitleaks detect \
--no-git \
--source . \
--redact \
--exit-code 1
# npm-advisory check (against pnpm-lock.yaml). Run last so
# `pnpm install` does not pollute the working tree before the
# scanners above.
- run: pnpm install --frozen-lockfile
- run: pnpm ci:audit
commits:
# PRs opened by Renovate (apf-portal-bot) carry commit messages
# generated from a vetted Conventional-Commits template — running
# commitlint on them is tautological. Per ADR-0017 amendment.
if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'apf-portal-bot'
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: COMMIT_LINT_FROM=origin/main pnpm ci:commits
perf:
# Skip the Lighthouse run on PRs opened by Renovate (apf-portal-bot):
# the per-PR perf signal on a dep bump is essentially zero (no
# routes yet, bundle is the static placeholder), and the Lighthouse
# round-trip burns several minutes per PR. Push events on `main`
# still run perf — we catch regressions immediately post-merge,
# not pre-merge. Per ADR-0017 amendment.
if: github.event_name != 'pull_request' || github.event.pull_request.user.login != 'apf-portal-bot'
runs-on: [self-hosted, on-prem]
# Lighthouse CI drives a real Chrome instance; the default act runner
# image (catthehacker/ubuntu:act-22.04) ships without one. The :full
# variant adds Chrome, Firefox, and the GUI-test toolchain — pinned
# to the same Ubuntu 22.04 base as the default labels for parity.
container:
image: catthehacker/ubuntu:full-22.04
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm ci:perf
- uses: actions/upload-artifact@v7
if: always()
with:
name: lighthouseci-report
path: .lighthouseci/
retention-days: 30
a11y:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Placeholder until the e2e a11y suite (axe-core via Playwright,
# per ADR-0016) is wired with the first real screens. The job
# exists so branch protection can require it from day one - it
# currently no-ops with a clear message.
- run: echo "a11y gate placeholder - axe-core via Playwright wires up with the first real screens (ADR-0016)."