Compare commits

..

1 Commits

Author SHA1 Message Date
APF Portal Bot 48abd08ec9 fix(deps): update dependency @scalar/nestjs-api-reference to v1.1.16
CI / commits (pull_request) Has been skipped
CI / perf (pull_request) Has been skipped
CI / scan (pull_request) Failing after 1m57s
CI / a11y (pull_request) Successful in 3m14s
CI / check (pull_request) Successful in 4m32s
Docs site / build (pull_request) Successful in 2m36s
2026-05-15 17:36:31 +00:00
257 changed files with 3798 additions and 29418 deletions
-65
View File
@@ -1,65 +0,0 @@
// `apf-portal` devcontainer — VSCode Dev Containers spec.
//
// Pinned Node + pnpm via corepack via the official typescript-node image
// (Bookworm base). The container has access to the host docker daemon
// through the docker socket (`docker-outside-of-docker` feature), so
// `infra/local/dev.sh up` on the HOST creates the postgres/redis/otel
// containers and this container connects to them through the shared
// `apf-portal-dev` network.
//
// Workflow:
// 1. On the host: ./infra/local/dev.sh up (creates the network)
// 2. In VSCode: F1 → "Dev Containers: Reopen in Container"
// 3. Inside the container: pnpm exec nx serve portal-bff (connects to postgres:5432)
{
"name": "apf-portal",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": false,
"configureZshAsDefaultShell": false
}
},
// `initializeCommand` runs on the HOST before the container starts.
// Fail fast if the infra network isn't up — otherwise the BFF would
// get connection-refused on every Postgres query inside the container.
"initializeCommand": "docker network inspect apf-portal-dev > /dev/null 2>&1 || { echo '❌ Bring up infra first: ./infra/local/dev.sh up' >&2 ; exit 1 ; }",
// Attach to the same Compose network as postgres/redis/otel so DNS
// resolution (`postgres:5432`) works from inside the container.
"runArgs": ["--network=apf-portal-dev"],
"forwardPorts": [4200, 4300, 3000, 8081, 16686, 4317, 4318],
"portsAttributes": {
"4200": { "label": "portal-shell", "onAutoForward": "notify" },
"4300": { "label": "portal-admin", "onAutoForward": "notify" },
"3000": { "label": "portal-bff", "onAutoForward": "notify" },
"8081": { "label": "pgweb", "onAutoForward": "silent" },
"16686": { "label": "jaeger UI", "onAutoForward": "silent" },
"4317": { "label": "otel gRPC", "onAutoForward": "silent" },
"4318": { "label": "otel HTTP", "onAutoForward": "silent" }
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"remoteUser": "node",
"containerEnv": {
// Nx + Angular CLI memory ceiling — 4 GB. Bumping past the default
// 2 GB avoids OOM in `nx build` on large monorepos.
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Angular.ng-template",
"Prisma.prisma",
"ms-azuretools.vscode-docker",
"EditorConfig.EditorConfig"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Runs once, on the container's first start. Idempotent.
set -euo pipefail
echo "▶ Enabling corepack (pnpm shim from package.json#packageManager)…"
corepack enable
echo "▶ pnpm install --frozen-lockfile…"
pnpm install --frozen-lockfile
echo "✓ Devcontainer ready."
echo " Suggested next: pnpm exec nx run-many -t lint test --parallel=3"
-9
View File
@@ -44,15 +44,6 @@ 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]
-9
View File
@@ -31,15 +31,6 @@ pnpm-debug.log*
*.pem
*.key
# Tenant-private Entra group GUIDs per ADR-0025 (commit only the .example.json)
infra/*-tenant.entra.json
!infra/*-tenant.entra.example.json
# Tenant-private Entra per-persona oids consumed by prisma/seed.ts
# (ADR-0026 §"Seed personas"). Same pattern — commit only the example.
infra/*-tenant.personas.json
!infra/*-tenant.personas.example.json
# OS / editor scrap
.DS_Store
Thumbs.db
-159
View File
@@ -1,159 +0,0 @@
# Per ADR-0028 (CI/CD migration from Gitea Actions to GitLab CE).
# Thin YAML — orchestration lives in package.json scripts (ci:check,
# ci:catalogue-drift, ci:audit, ci:commits, ci:perf, ci:gzip-budgets)
# and Nx targets. Any change to gate behaviour belongs in those
# scripts, not in this file — see ADR-0015 §"Thin pipeline YAML…"
# (principle preserved by ADR-0028 at the migration boundary).
#
# Phase 2 of ADR-0028: ships alongside .gitea/workflows/ci.yml. Both
# pipelines must remain in parity until cutover (Phase 3), at which
# point the Gitea workflow gets deleted.
include:
# GitLab built-in scanners — replace the manual Trivy + gitleaks
# install in .gitea/workflows/ci.yml. Both add their own jobs to
# the pipeline; their default stage is `test`, which is mapped in
# `stages:` below.
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
stages:
- check
- test
- commits
- perf
- a11y
workflow:
rules:
# MR pipelines — covers GitLab MR review flow once it lands.
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Direct pushes — covers post-merge runs on main today, and Phase 2
# parity testing on feature branches mirrored from Gitea. To be
# tightened to `$CI_COMMIT_BRANCH == "main"` at the Phase 3 cutover
# once GitLab is the source of truth.
- if: $CI_PIPELINE_SOURCE == "push"
# Shared shape for jobs that need Node + pnpm. Hidden (`.` prefix) so
# GitLab does not try to execute it. Inheriting jobs `extends: .node-job`.
.node-job:
image: node:24-bookworm
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store/
before_script:
- corepack enable
- corepack prepare pnpm@10.33.4 --activate
- pnpm config set store-dir "$CI_PROJECT_DIR/.pnpm-store"
check:
extends: .node-job
stage: check
# `nx affected` needs a base SHA to diff against. Mirrors the
# equivalent step in .gitea/workflows/ci.yml — same logic, GitLab
# variable names. Replaces nrwl/nx-set-shas (GitHub-only).
script:
- |
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --depth=100
export NX_BASE=$(git merge-base HEAD "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME")
else
export NX_BASE="HEAD~1"
fi
export NX_HEAD="HEAD"
- pnpm install --frozen-lockfile
- pnpm ci:check
# Catalogue-vs-code drift gate per ADR-0025 §"Confirmation". The
# script's own unit tests run first — if they fail the gate itself
# is broken and the actual catalogue check would be noise.
- pnpm ci:catalogue-drift:test
- pnpm ci:catalogue-drift
audit:
extends: .node-job
stage: test
# npm-advisory check against pnpm-lock.yaml. Trivy's dependency-vuln
# role from the Gitea workflow is now covered by the SAST include
# (which includes Dependency Scanning analyzers); `pnpm audit` stays
# in-pipeline as defense in depth against the npm advisory DB
# specifically.
script:
- pnpm install --frozen-lockfile
- pnpm ci:audit
commits:
extends: .node-job
stage: commits
# MRs opened by Renovate (apf-portal-bot) carry commit messages from
# a vetted Conventional-Commits template — running commitlint on
# them is tautological. Per ADR-0017 amendment.
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
variables:
# Default `GIT_DEPTH: 20` is too shallow to diff against
# origin/main on long-running branches. Full clone so commitlint
# can walk all the way back.
GIT_DEPTH: 0
script:
- git fetch origin main
- pnpm install --frozen-lockfile
- COMMIT_LINT_FROM=origin/main pnpm ci:commits
perf:
extends: .node-job
stage: perf
# Lighthouse CI drives a real Chrome via chrome-launcher, which
# probes the PATH / CHROME_PATH for a standard Chrome/Chromium
# binary. The Playwright image bundles Chromium under /ms-playwright
# with a non-standard name chrome-launcher cannot discover, so we
# stay on the base Node image and apt-install Debian's `chromium`
# (lands at /usr/bin/chromium). The future ADR-0016 axe-core e2e job
# — which drives Playwright directly — will use the Playwright image
# instead: the two tools want different things, so they get
# different images rather than one image that serves both poorly.
image: node:24-bookworm
variables:
CHROME_PATH: /usr/bin/chromium
before_script:
- !reference [.node-job, before_script]
- apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium
# Skip on Renovate MRs (same rationale as commits + the perf signal
# on a dep bump is essentially zero). Push events on `main` still
# run perf — we catch regressions immediately post-merge, not
# pre-merge. Per ADR-0017 amendment.
rules:
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN != "apf-portal-bot"
script:
- pnpm install --frozen-lockfile
- pnpm ci:perf
artifacts:
when: always
paths:
- .lighthouseci/
expire_in: 30 days
a11y:
extends: .node-job
stage: a11y
# 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 — currently no-ops
# with a clear message.
script:
- echo "a11y gate placeholder - axe-core via Playwright wires up with the first real screens (ADR-0016)."
# Place SAST + Secret Detection in the `test` stage to mirror the
# Gitea workflow's grouping (where Trivy + gitleaks + audit all
# lived in the `scan` job). Override does not change behaviour —
# only stage placement. The included templates set sensible defaults
# (run on default branch + MRs, full repo on default branch / diff
# on MRs); revisited in Phase 3 if pipeline duration becomes a pain.
sast:
stage: test
secret_detection:
stage: test
@@ -1,30 +0,0 @@
<!--
MR title format — becomes the squash-merge subject on main, validated by commitlint.
<type>(<scope>): <short description>
Examples:
feat(portal-shell): add user-preferences panel skeleton
fix(portal-bff): correct env var bracket access
docs(decisions): add ADR-0018 for security baseline
chore(deps): bump @nx/* to 22.7.2
Imperative mood, lowercase, no trailing period, target ≤ 70 chars.
See docs/development.md §7 for the full convention (types, scopes).
-->
## Summary
## Motivation
## Implementation notes
## Verification
- [ ] `pnpm ci:check` green locally
- [ ] `pnpm ci:audit` green (or pre-existing drift acknowledged)
- [ ] Tested manually:
- [ ] Architecture diagram updated (if `docs/architecture.md` was affected)
- [ ] ADR amended or added (if a decision changed)
## Related
-5
View File
@@ -5,8 +5,3 @@
/.nx/workspace-data
.nx/self-healing
.angular
# Generated gRPC TypeScript stubs (output of `pnpm run grpc:codegen`).
# Hand-formatting is not productive — overwritten on next regen — and
# ts-proto's output is internally consistent.
apps/portal-bff/src/grpc/gen/
+8 -22
View File
@@ -43,7 +43,7 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md).
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md).
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md).
- **CI/CD:** **Gitea Actions today, migrating to GitLab CE on `vm-gitlab` per [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md)** (decision-only ADR accepted; 4-phase rollout in follow-up PRs — the architectural principles below carry over unchanged from [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md), only the host / pipeline file / runner type / scan tooling change). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and `perf` (per ADR-0017). Self-hosted runners on-prem (`act_runner` today, GitLab Runner with Docker executor post-migration). Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits required on `main` once the migration cutover lands (ADR-0028's revisit of ADR-0015's "recommended" stance).
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md).
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + TailwindCSS** (spartan-ng _library_ deferred until it reaches 1.0.0; v1 components are written in-house in `libs/shared/ui/` on Angular CDK, applying the spartan-ng _philosophy_ of headless primitives + utility CSS + copy-paste). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
@@ -51,35 +51,23 @@ The structural, security, observability, and quality choices are recorded as ADR
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `Portal.Admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
- **Documentation site:** `docs/**/*.md` rendered as a separate static site via **VitePress** (Vite-based, Node-only toolchain, Markdown-first). Mermaid diagrams via `vitepress-plugin-mermaid`. Deployed on its own hostname behind the shared reverse-proxy; CI hook on `docs/` changes rebuilds + publishes. Decoupled from the apps — content lives in `docs/`, no in-app Markdown viewer — see [ADR-0022](docs/decisions/0022-docs-site-vitepress.md).
- **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `<title>`/`<desc>`, `<details>` tabular fallback, colour-blind-safe palettes, AA-contrast text, `prefers-reduced-motion` gate). Bundle stays under [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md)'s lazy-chunk cap via per-`d3-*` module tree-shaking. Future bespoke visualisations land in raw D3 inside the same lib — see [ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md).
- **AI service relay:** dedicated `apf-ai-service` repo (ASP.NET Core, Microsoft Agent Framework) consumed via native gRPC HTTP/2 only — proto contract vendored under `apps/portal-bff/src/grpc/proto/apf-ai/` with `ts-proto` codegen committed alongside. BFF dials with `@grpc/grpc-js` (h2c in dev, h2 + TLS in prod), bridges `ChatService.Chat` to `text/event-stream` for the SPA, exposes `RagService.Search` and `ModelsService.ListModels` as plain JSON endpoints. Identity travels as an unsigned `Principal` (subject, roles, attributes) in the proto body for the POC, hashed via the audit module's `HashUserIdService` so portal and AI service audit trails join on the same `actor_id_hash`. Production hardening (signed envelope vs mTLS) deferred — see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
- **Authorization model:** three orthogonal axes — **privileges** (Entra app roles, `Portal.*`), **functional roles** (Entra security groups → curated `apf-role-*` slug catalogue, 24 entries v1), **scopes** (portal-side `user_scopes` table, future Pléiades feed; kinds = `self / etablissement:<structure-code> / delegation:<dept> / region:<insee> / siege / unrestricted`, see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) for the `Structure.code` semantics). Composed at sign-in into a session-resident `Principal`; portal guards consume the structured shape, a deterministic `PrincipalProjector` flattens it to the AI-service `roles[]` contract. Replaces stargate's linear hierarchy. Catalogues are closed-set, drift gated by CI — see [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
- **Portal-side identity model:** `Person` golden record (stable identity, can exist without a portal account — workforce pre-provisioning, dossier bénéficiaires, alumni) + `User` overlay (one-to-zero-or-one with Person, lazy-created on first OIDC callback in v1; carries portal-only state like `lastSignInAt`). `UserScope` backs the ADR-0025 scope axis with opaque `value` strings referencing ADR-0027's `Structure.code` / `Delegation.code` / `Region.code` — no FK at the DB level so historical rows survive structure decommissioning; admin-UI write path validates. v1 dedup uses `entraOid` only; `Person.email` is an indexed attribute, not a unique key, because two distinct humans genuinely share emails (shared aliases, generic `info@`, upstream-feed errors). Facets (Salarié / Élu / Adhérent / Bénéficiaire) + Pléiades / Acteurs+ sync + operator-confirmed Person-merge flow deferred to ADR-0029 — see [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md).
- **Portal-side organisational hierarchy:** `Region` (INSEE 2-digit) → `Delegation` (department 23-char) → `Structure` with `kind` discriminator (`medico_social` / `antenne` / `dispositif` / `entreprise_adaptee` / `mouvement` / `administratif` / `siege`, aligned with cascade's `Structure.type`). `Structure.code` is the portal-internal string PK, externally meaningful: for medico-social rows it equals the FINESS (9 digits) and round-trips cleanly through scope literals (`etablissement:0330800013`) and URLs; for non-FINESS rows it is an APF-internal slug (`siege`, `apf-bdx-merignac`, `ea-toulouse`, …). `finess` / `siret` / `codePaie` are nullable, unique-when-present attributes — populated where the upstream registry has the structure on file. v1 ships a small inline-migration seed (test-tenant scope: Région Nouvelle-Aquitaine, Délégation 33, a handful of médico-social + siège); the full cascade-driven inventory sync, plus `Pole` / `Service` / arbitrary nesting / per-source enrichment, land additively with ADR-0029 — see [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md).
- **Runtime:** Node.js latest LTS major.
- **Local dev environment:** three coexisting run modes — native `pnpm nx serve`, the VSCode devcontainer, and a Docker Compose `apps` profile that boots all three Nx dev servers without a native Node/pnpm toolchain (`./infra/local/dev.sh up apps`). Dev-only (production images deferred to the ADR-0028 Container Registry work); shared `Dockerfile.dev`, repo bind-mounted for hot reload, `node_modules`/Nx cache in named volumes, BFF entrypoint runs `prisma generate` + `migrate deploy` — see [ADR-0030](docs/decisions/0030-dockerised-dev-mode.md).
## Repository status
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the seven lib roots (`libs/feature/auth`, `libs/shared/auth`, `libs/shared/charts`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` plus the ADR-0025 catalogue-drift gate on every PR.
The Nx workspace is **scaffolded and operational**. The three apps (`portal-shell`, `portal-admin`, `portal-bff`) and the four lib roots (`libs/feature/`, `libs/shared/state`, `libs/shared/tokens`, `libs/shared/ui`, `libs/shared/util`) are in place; CI runs `format:check / lint / test / build` on every PR.
ADRs 0001 → 0028 plus ADR-0030 are accepted and cover the structural, security, observability, quality, i18n, admin-app, docs-site, charts, AI-relay, authorization, portal-side identity + organisational hierarchy, CI/CD platform migration, and dockerised local-dev mode choices. ADR-0028 supersedes only ADR-0015's "Gitea Actions" platform choice — the rest of ADR-0015's architectural principles carry over unchanged; the 4-phase migration (mirror → parallel pipelines → cutover → cleanup) ships in follow-up PRs. ADR-0029 (cascade / Pléiades / Acteurs+ syncs + facet schemas) is the next proposed addition — its number is reserved ahead of ADR-0030, which was written first. Until ADR-0026 + ADR-0027 implementation PRs ship, ADR-0025's stubs (`Principal.user.{id, personId}` placeholders, `StubScopeResolver`'s `unrestricted` blanket return) remain in place. **Shipped on `main`:**
ADRs 0001 → 0022 are accepted and cover the structural, security, observability, quality, i18n, admin-app, and docs-site choices. **Shipped on `main`:**
- **Phase-1 foundation** — Nx workspace, Angular `portal-shell`, NestJS `portal-bff`, Prisma + Postgres, Pino + OpenTelemetry, Husky/lint-staged/commitlint, Gitea Actions CI.
- **Phase-2 auth + audit + security** — OIDC Auth Code + PKCE via MSAL Node, Redis sessions with AES-256-GCM at rest, idle 30 min sliding + absolute 12 h hard ceiling, RP-initiated logout, double-submit CSRF, `audit.events` append-only schema with role-based grants, helmet + env-driven CORS allowlist + rate limiting + structured error envelope (see [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md)).
- **Phase-3a admin app** — `portal-admin` SPA with brand tokens, routing, user-list reader (`/admin/users`), and audit-log viewer with statistics and integrated charts (`/admin/audit`). CMS for static pages and menu management not yet implemented.
- **AI relay surface + live consumer** — vendored protos + `AiClientModule` (gRPC clients, Principal mapper, metadata builder) + `AiBridgeController` exposing `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models` (see [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)). Chatbot widget live in `portal-shell` at `apps/portal-shell/src/app/features/chatbot/`.
- **Docs static site** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — VitePress + Mermaid renderer at `docs/.vitepress/`, dedicated `docs-site.yml` workflow that rebuilds + publishes on every `docs/**` change.
- **Charts lib + audit-page dashboards** ([ADR-0023](docs/decisions/0023-charts-d3-observable-plot.md)) — `libs/shared/charts/` with `BarChart`, `DonutChart`, `StackedBarChart` (D3 + Observable Plot, headless / a11y-baked-in), integrated into the `/admin/audit` page for daily-volume + outcome-breakdown + event-type-over-time views.
- **Authorization model + guards** ([ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)) — `libs/shared/auth/` exporting the closed catalogues (4 privileges, 24 functional roles, 6 scope kinds), `Principal` shape, pure matchers, and `EntraGroupToRoleResolver`. BFF-side `PrincipalBuilder` composing the three axes at sign-in from Entra `roles` + `groups` claims + a stub `ScopeResolver`. `@RequirePrivilege` / `@RequireRole` / `@RequireScope` route decorators + guards with the ADR-0021 structured-error envelope on denial; `AdminRoleGuard` migrated to read `principal.privileges`. CI drift gate (`scripts/check-catalogue-drift.mjs`) asserting every decorator literal is in the catalogue.
- **Phase-3a admin app skeleton** — `portal-admin` SPA exists with brand tokens and routing; business modules (CMS, menu management, user list, audit log viewer) not yet implemented.
**Still on the roadmap:**
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — module scaffolded (`obo.strategy`, `signed-assertion.strategy`, JWKS publisher, encrypted token cache); no v1 consumer yet. Wires in when the first business route needs an Entra-protected API.
- `@RequireMfa()` step-up consumer routes ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md)) — guard + decorator shipped; awaiting first sensitive route that needs explicit freshness enforcement beyond the Conditional Access baseline.
- **`@RequireScope` Prisma-backed resolver + first consumer surface** — `StubScopeResolver` returns `unrestricted` for everyone in v1 per [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) §331. Implementation lands across [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (accepted: `Person` + `User` + `UserScope`) and [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (accepted: `Region` / `Delegation` / `Structure` with `kind` discriminator + nullable FINESS / SIRET). Sequencing: ADR-0026 PR 1 + ADR-0027 PR 1 ship schema in parallel; ADR-0026 PR 2 then lands the `PrismaScopeResolver` + admin scope-seeding UI + test-tenant seed (which references ADR-0027's `Structure.code` values). The follow-up [ADR-0029](#) covers Pléiades / Acteurs+ / cascade syncs + facet schemas.
- **Proto-drift CI gate for the AI relay** ([ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)) — asserts the vendored `apf-ai-service` proto files stay in lockstep with the upstream contract.
- **Admin app — CMS & menu management** ([ADR-0020](docs/decisions/0020-portal-admin-app.md)) — multilingual static-page editor + navigation menu builder. The user-list + audit-log-viewer modules already exist; the CMS/menu pair is the remaining v1 module scope.
- `DownstreamApiClient` + OBO ([ADR-0014](docs/decisions/0014-downstream-api-access-obo-pattern.md)) — no v1 consumer yet; will land when the first business route needs an Entra-protected API.
- `@RequireMfa()` / `@RequireAdmin()` guards ([ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md), [ADR-0020](docs/decisions/0020-portal-admin-app.md)) — designed-in, awaiting first consumer route.
- **Docs static-site implementation** ([ADR-0022](docs/decisions/0022-docs-site-vitepress.md)) — ADR accepted, chantier (VitePress install + `.vitepress/config.ts` + `docs-site.yml` workflow) lands next.
- **Strategic security baseline ADR** — separate from the implementation-level [ADR-0021](docs/decisions/0021-phase-2-security-baseline.md). Remains **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly NIS 2). When it lands it will either confirm 0021 or supersede pieces of it.
## Commands once the workspace exists
@@ -109,10 +97,8 @@ pnpm nx format:check
## Environment conventions
- **Two development environments.** `local` (Windows-WSL or native macOS / Linux on the workstation) and `development` (Debian 13 VM at `10.100.201.21` — the default for new devs, replaces WSL). A `hybrid` sub-mode runs the IDE + Nx servers on the workstation while reaching the infra services (postgres / redis / otel) on the dev VM through SSH tunnels. Full procedure: [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md). The legacy WSL flow remains documented in [docs/setup/02-wsl-terminal-setup.md](docs/setup/02-wsl-terminal-setup.md).
- **Two IDE flows on the dev VM** — VSCode Remote-SSH (default; transparent equivalent of the WSL flow) and Devcontainer (`.devcontainer/devcontainer.json` shipped, Node + pnpm pinned in the image, mounts the docker socket so the host's `apf-portal-dev` Compose network is reachable from inside). Independently of the IDE flow, the apps can run **natively** (`pnpm nx serve`) or as **Docker Compose services** (`./infra/local/dev.sh up apps`, no native toolchain — ADR-0030); the "which mode when" table is in [docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md).
- **Never install Angular globally.** Use `pnpm dlx` for one-off CLI invocations and project-local `pnpm nx ...` for everything else — versions stay pinned per project.
- **On WSL: work inside the WSL filesystem** (`~/Works/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times. On the dev VM the analogous rule is "stay on the VM disk, do not work over SSHFS / network mounts".
- **Work inside the WSL filesystem** (`~/dev/...`), never under `/mnt/c` — the latter has severe I/O penalties that break Nx caching and dev-server reload times.
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
+3 -10
View File
@@ -52,8 +52,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "8kb"
"maximumWarning": "5kb",
"maximumError": "6kb"
},
{
"type": "bundle",
@@ -84,8 +84,7 @@
"continuous": true,
"executor": "@angular/build:dev-server",
"options": {
"port": 4300,
"proxyConfig": "apps/portal-admin/proxy.conf.js"
"port": 4300
},
"configurations": {
"production": {
@@ -93,12 +92,6 @@
},
"development": {
"buildTarget": "portal-admin:build:development"
},
"https": {
"buildTarget": "portal-admin:build:development",
"ssl": true,
"sslKey": ".secrets/dev-tls.key",
"sslCert": ".secrets/dev-tls.pem"
}
},
"defaultConfiguration": "development"
-21
View File
@@ -1,21 +0,0 @@
// Angular dev-server proxy for portal-admin.
//
// Mirrors apps/portal-shell/proxy.conf.js — same rationale, same
// `/api → ${BFF_TARGET:-http://localhost:3000}` rule. The admin app
// talks to the same BFF (ADR-0020 §"Where does the admin app live"),
// just at admin-specific paths under `/api/admin/...`; the proxy
// match on `/api` covers both surfaces.
//
// JS form deliberate — only this form can read `process.env` so the
// Docker / native target swap (BFF_TARGET in dev.compose.yml) works
// without a rebuild.
const target = process.env['BFF_TARGET'] ?? 'http://localhost:3000';
module.exports = {
'/api': {
target,
secure: false,
changeOrigin: true,
},
};
-6
View File
@@ -4,7 +4,6 @@ import { authGuard } from 'feature-auth';
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
const auditTitle = $localize`:@@route.audit.title:Audit log — APF Portal Admin`;
const usersTitle = $localize`:@@route.users.title:Users — APF Portal Admin`;
const userScopesTitle = $localize`:@@route.user-scopes.title:User scopes — APF Portal Admin`;
const profileTitle = $localize`:@@route.profile.title:Profile — APF Portal Admin`;
export const appRoutes: Route[] = [
@@ -24,11 +23,6 @@ export const appRoutes: Route[] = [
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
title: usersTitle,
},
{
path: 'users/:oid/scopes',
loadComponent: () => import('./pages/user-scopes/user-scopes').then((m) => m.UserScopesPage),
title: userScopesTitle,
},
{
path: 'profile',
canActivate: [authGuard],
@@ -32,7 +32,6 @@
[username]="state.user.username"
[initials]="initials()"
[items]="userMenuItems"
[role]="roleLabel"
[signedInAsLabel]="signedInAsLabel"
[signOutLabel]="signOutLabel"
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
@@ -56,13 +56,6 @@ export class AdminHeader {
protected readonly signedInAsLabel = 'Signed in as';
protected readonly signOutLabel = 'Sign out';
// Static role label — every reader who reaches portal-admin holds
// the `Portal.Admin` Entra app role (that's the `AdminRoleGuard`
// precondition for `/api/admin/*`), so the chip is unconditional
// and doesn't need to read `req.session.user.roles`. No i18n marks
// either, per ADR-0020's source-locale-only chrome.
protected readonly roleLabel = 'Administrator';
protected triggerAriaLabel(displayName: string): string {
return `User menu — signed in as ${displayName}`;
}
@@ -28,24 +28,6 @@ export interface AdminAuditPage {
readonly offset: number;
}
/**
* Aggregated audit-event stats — mirrors `AdminAuditStats` on the
* BFF side. Powers the Charts tab of the audit viewer.
*/
export interface AdminAuditStats {
readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
readonly total: number;
}
/**
* Filter shape sent as query params on the stats endpoint. Same as
* {@link AdminAuditQuery} minus pagination — the stats endpoint
* always aggregates the whole filtered set.
*/
export type AdminAuditStatsQuery = Omit<AdminAuditQuery, 'limit' | 'offset'>;
/**
* Filter+pagination shape sent as query params. All fields are
* optional; missing values fall back to the BFF defaults (limit=50,
@@ -83,22 +65,6 @@ export class AuditEventsService {
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
query(filters: AdminAuditQuery): Observable<AdminAuditPage> {
const params = this.toHttpParams({ ...filters });
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
}
/**
* Calls `GET /api/admin/audit/stats` with the same filter shape
* minus pagination. The endpoint Redis-caches results for 5
* minutes per filter-hash (see ADR-0013 §"Reader endpoints"); the
* SPA itself doesn't try to cache.
*/
stats(filters: AdminAuditStatsQuery): Observable<AdminAuditStats> {
const params = this.toHttpParams({ ...filters });
return this.http.get<AdminAuditStats>(`${this.bffBaseUrl}/admin/audit/stats`, { params });
}
private toHttpParams(filters: Record<string, unknown>): HttpParams {
let params = new HttpParams();
for (const [key, value] of Object.entries(filters)) {
// Drop empty strings — Nest's ValidationPipe treats `?foo=`
@@ -111,6 +77,6 @@ export class AuditEventsService {
}
params = params.set(key, String(value));
}
return params;
return this.http.get<AdminAuditPage>(`${this.bffBaseUrl}/admin/audit`, { params });
}
}
+73 -197
View File
@@ -103,210 +103,86 @@
</div>
</form>
<div class="tablist" role="tablist" aria-label="Audit log views">
<button
type="button"
role="tab"
id="tab-table"
class="tab"
[class.tab--active]="tab() === 'table'"
[attr.aria-selected]="tab() === 'table'"
[attr.aria-controls]="'panel-table'"
[attr.tabindex]="tab() === 'table' ? 0 : -1"
(click)="setTab('table')"
(keydown.arrowRight)="setTab('charts')"
>
Table
</button>
<button
type="button"
role="tab"
id="tab-charts"
class="tab"
[class.tab--active]="tab() === 'charts'"
[attr.aria-selected]="tab() === 'charts'"
[attr.aria-controls]="'panel-charts'"
[attr.tabindex]="tab() === 'charts' ? 0 : -1"
(click)="setTab('charts')"
(keydown.arrowLeft)="setTab('table')"
>
Charts
</button>
</div>
<div class="status-bar" role="status" aria-live="polite">
@if (tab() === 'table') { @if (loading()) {
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (page(); as p) {
<span class="status-line">{{ resultRange() }}</span>
} } @else { @if (statsLoading()) {
<span class="status-line">Loading statistics…</span>
} @else if (statsError(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
} @else if (stats(); as s) {
<span class="status-line">{{ s.total }} matching event(s) aggregated</span>
} }
}
</div>
<section
id="panel-charts"
role="tabpanel"
aria-labelledby="tab-charts"
class="tab-panel"
[hidden]="tab() !== 'charts'"
>
@if (stats(); as s) { @if (hasChartData()) {
<p class="charts-note">
Aggregations are computed across the full filtered set (server-side), not just the events on
the current page. Results are cached for 5 minutes per filter combination.
</p>
<div class="charts-grid">
<div class="chart-tile">
<lib-bar-chart
[data]="s.dailyVolume"
xKey="day"
yKey="count"
xLabel="Date (UTC)"
yLabel="Events"
caption="Events per day"
description="Bar chart of audit event volume per calendar day across the full filtered set."
ariaLabel="Events per day — bar chart"
/>
</div>
<div class="chart-tile">
<lib-donut-chart
[data]="s.outcomeBreakdown"
categoryKey="outcome"
valueKey="count"
[centerLabel]="s.total.toString()"
[colorMap]="outcomeColorMap"
caption="Outcome breakdown"
description="Donut chart of audit event outcomes (success in green, denied in orange, failure in red) across the full filtered set."
ariaLabel="Outcome breakdown — donut chart"
/>
</div>
<div class="chart-tile chart-tile--wide">
<lib-stacked-bar-chart
[data]="s.eventTypeByDay"
xKey="day"
yKey="count"
seriesKey="eventType"
xLabel="Date (UTC)"
yLabel="Events"
caption="Events per day, by event type"
description="Stacked bar chart of audit event volume per calendar day, broken down by event_type, across the full filtered set."
ariaLabel="Events per day, by event type — stacked bar chart"
/>
</div>
</div>
} @else {
<p class="empty">No audit events match the current filters.</p>
} } @else if (!statsLoading() && !statsError()) {
<p class="charts-note">Switch to this tab to load statistics.</p>
}
</section>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No audit events match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Event</th>
<th scope="col">Audience / outcome</th>
<th scope="col">Actor / subject</th>
<th scope="col">Trace</th>
<th scope="col">Payload</th>
</tr>
</thead>
<tbody>
@for (event of p.items; track event.id) {
<tr>
<td class="cell-timestamp">{{ formatTimestamp(event.createdAt) }}</td>
<td class="cell-event">{{ event.eventType }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ event.audience }}</span>
<span
class="outcome-badge"
[class.outcome-badge--success]="event.outcome === 'success'"
[class.outcome-badge--failure]="event.outcome === 'failure'"
[class.outcome-badge--denied]="event.outcome === 'denied'"
>{{ event.outcome }}</span
>
</td>
<td class="cell-actor">
<div class="actor-hash">{{ event.actorIdHash ?? '(anonymous)' }}</div>
@if (event.subject; as subject) {
<div class="subject">{{ subject }}</div>
}
</td>
<td class="cell-trace">{{ event.traceId ?? '—' }}</td>
<td class="cell-payload">
@if (event.payload) {
<details>
<summary>view</summary>
<pre>{{ formatPayload(event.payload) }}</pre>
</details>
} @else {
<span class="dim"></span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<section
id="panel-table"
role="tabpanel"
aria-labelledby="tab-table"
class="tab-panel"
[hidden]="tab() !== 'table'"
>
@if (page(); as p) { @if (p.items.length === 0) {
<p class="empty">No audit events match the current filters.</p>
} @else {
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Event</th>
<th scope="col">Audience / outcome</th>
<th scope="col">Actor / subject</th>
<th scope="col">Trace</th>
<th scope="col">Payload</th>
</tr>
</thead>
<tbody>
@for (event of p.items; track event.id) {
<tr>
<td class="cell-timestamp">{{ formatTimestamp(event.createdAt) }}</td>
<td class="cell-event">{{ event.eventType }}</td>
<td class="cell-aud">
<span class="aud-badge">{{ event.audience }}</span>
<span
class="outcome-badge"
[class.outcome-badge--success]="event.outcome === 'success'"
[class.outcome-badge--failure]="event.outcome === 'failure'"
[class.outcome-badge--denied]="event.outcome === 'denied'"
>{{ event.outcome }}</span
>
</td>
<td class="cell-actor">
@if (event.actorIdHash; as hash) {
<button
type="button"
class="actor-hash actor-hash--clickable"
(click)="filterByActor(hash)"
[attr.title]="'Filter the table on ' + hash"
>
{{ hash }}
</button>
} @else {
<div class="actor-hash">(anonymous)</div>
} @if (event.subject; as subject) {
<div class="subject">{{ subject }}</div>
}
</td>
<td class="cell-trace">
@if (event.traceId; as traceId) {
<a
class="trace-link"
[href]="jaegerUrl(traceId)"
target="_blank"
rel="noopener noreferrer"
[attr.title]="'Open trace ' + traceId + ' in Jaeger'"
>{{ traceId }}</a
>
} @else { — }
</td>
<td class="cell-payload">
@if (event.payload) {
<details>
<summary>view</summary>
<pre>{{ formatPayload(event.payload) }}</pre>
</details>
} @else {
<span class="dim"></span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
<nav class="pagination" aria-label="Pagination">
<button
type="button"
class="btn btn--secondary"
(click)="previous()"
[disabled]="!hasPreviousPage() || loading()"
>
Previous
</button>
<button
type="button"
class="btn btn--secondary"
(click)="next()"
[disabled]="!hasNextPage() || loading()"
>
Next
</button>
</nav>
} }
</section>
@@ -194,133 +194,6 @@
color: #9ca3af;
}
// Tabs above the content. WAI-ARIA pattern: `role="tablist"` on the
// container, `role="tab"` on each button, roving `tabindex` (the
// active tab is reachable via Tab, inactive ones via arrow keys
// once focus is in the list). The visual treatment is a thin
// underline on the active tab — minimal furniture, lets the panel
// content carry the eye.
.tablist {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 1rem;
}
:host-context(.dark) .tablist {
border-bottom-color: #1f2937;
}
.tab {
appearance: none;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 0.5rem 0.75rem;
margin-bottom: -1px;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition:
color 0.12s ease-out,
border-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .tab {
color: #9ca3af;
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
.tab--active {
color: var(--color-brand-primary-600, #1d4ed8);
border-bottom-color: var(--color-brand-primary-600, #1d4ed8);
font-weight: 600;
}
:host-context(.dark) .tab--active {
color: var(--color-brand-primary-300, #93c5fd);
border-bottom-color: var(--color-brand-primary-300, #93c5fd);
}
.tab-panel {
// No box-styling — the panel inherits the page's surface.
// `[hidden]` is set imperatively when the tab isn't active.
}
// Charts panel container. Same surface tokens as `.filters` so the
// chart stack reads as one related block.
.charts {
margin: 0 0 1rem;
padding: 1rem;
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
:host-context(.dark) .charts {
background-color: #111827;
border-color: #1f2937;
}
.charts-heading {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
:where(.dark) & {
color: #f3f4f6;
}
}
.charts-note {
margin: 0.25rem 0 1rem;
font-size: 0.75rem;
color: #6b7280;
:where(.dark) & {
color: #9ca3af;
}
}
.charts-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 800px) {
grid-template-columns: 1fr;
}
}
// Grid items default to `min-width: auto`, which prevents the column
// from shrinking below the intrinsic width of its content. Without
// the override, Plot's fixed-width SVG could push the column wider
// than 1fr, busting the layout and forcing a horizontal scrollbar.
.chart-tile {
min-width: 0;
}
// The stacked-bar chart carries a legend and benefits from the
// full width; flag it via a modifier and span both columns.
.chart-tile--wide {
grid-column: 1 / -1;
}
.table-wrap {
overflow-x: auto;
background-color: #ffffff;
@@ -400,65 +273,6 @@
color: #9ca3af;
}
// The clickable variant of `.actor-hash` (rendered as a <button> so
// keyboard activation works) reuses the inline-hash typography but
// strips the native button chrome and surfaces a subtle hover
// affordance. Filter pivot per the same row's actor.
.actor-hash--clickable {
display: inline;
padding: 0;
margin: 0;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
text-decoration: underline dotted transparent;
text-underline-offset: 2px;
transition:
color 0.12s ease-out,
text-decoration-color 0.12s ease-out;
&:hover {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .actor-hash--clickable {
&:hover {
color: var(--color-brand-primary-300, #93c5fd);
}
}
// Trace-id deep-link into Jaeger. Same hash typography as the actor
// cell (already inherited via `.cell-trace`), plus a brand-coloured
// underline so investigators read it as "follow this".
.trace-link {
color: var(--color-brand-primary-600, #1d4ed8);
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
text-decoration-thickness: 2px;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500, #2563eb);
outline-offset: 2px;
border-radius: 0.125rem;
}
}
:host-context(.dark) .trace-link {
color: var(--color-brand-primary-300, #93c5fd);
}
.aud-badge,
.outcome-badge {
display: inline-block;
@@ -5,27 +5,8 @@ import {
AuditEventsService,
type AdminAuditPage,
type AdminAuditQuery,
type AdminAuditStats,
type AdminAuditStatsQuery,
} from './audit-events.service';
const STATS: AdminAuditStats = {
dailyVolume: [
{ day: '2026-05-12', count: 5 },
{ day: '2026-05-13', count: 7 },
],
outcomeBreakdown: [
{ outcome: 'success', count: 11 },
{ outcome: 'denied', count: 1 },
],
eventTypeByDay: [
{ day: '2026-05-12', eventType: 'auth.sign_in', count: 5 },
{ day: '2026-05-13', eventType: 'auth.sign_in', count: 6 },
{ day: '2026-05-13', eventType: 'admin.access_denied', count: 1 },
],
total: 12,
};
const PAGE: AdminAuditPage = {
items: [
{
@@ -59,34 +40,22 @@ const PAGE: AdminAuditPage = {
interface Fixture {
fixture: ReturnType<typeof TestBed.createComponent<AuditPage>>;
query: ReturnType<typeof vi.fn>;
stats: ReturnType<typeof vi.fn>;
}
function setup(opts?: {
initial?: AdminAuditPage | 'error';
initialStats?: AdminAuditStats | 'error';
status?: number;
}): Fixture {
function setup(opts?: { initial?: AdminAuditPage | 'error'; status?: number }): Fixture {
const initial = opts?.initial ?? PAGE;
const initialStats = opts?.initialStats ?? STATS;
const query = vi.fn().mockImplementation(() => {
if (initial === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initial);
});
const stats = vi.fn().mockImplementation(() => {
if (initialStats === 'error') {
return throwError(() => ({ status: opts?.status ?? 500 }));
}
return of(initialStats);
});
TestBed.configureTestingModule({
imports: [AuditPage],
providers: [{ provide: AuditEventsService, useValue: { query, stats } }],
providers: [{ provide: AuditEventsService, useValue: { query } }],
});
const fixture = TestBed.createComponent(AuditPage);
return { fixture, query, stats };
return { fixture, query };
}
async function flush(fixture: Fixture['fixture']): Promise<void> {
@@ -228,181 +197,4 @@ describe('AuditPage', () => {
expect(rows[0]?.querySelector('details')).not.toBeNull();
expect(rows[1]?.querySelector('details')).toBeNull();
});
it('renders trace_id as a Jaeger deep link (anchor with target=_blank)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
const link = rows[0]?.querySelector<HTMLAnchorElement>('.trace-link');
expect(link).not.toBeNull();
expect(link?.getAttribute('href')).toContain('/trace/trace-abc');
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toContain('noopener');
});
it('renders the dash placeholder for rows without a trace_id', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
expect(rows[1]?.querySelector('.trace-link')).toBeNull();
expect(rows[1]?.querySelector('.cell-trace')?.textContent?.trim()).toBe('—');
});
it('clicking an actor hash re-runs the query filtered on that hash + resets offset', async () => {
const { fixture, query } = setup();
await flush(fixture);
query.mockClear();
const root = fixture.nativeElement as HTMLElement;
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
rows[0]?.querySelector<HTMLButtonElement>('.actor-hash--clickable')?.click();
await flush(fixture);
expect(query).toHaveBeenCalledTimes(1);
const filters = query.mock.calls[0]?.[0] as AdminAuditQuery;
expect(filters.actorIdHash).toBe('hash(jane)');
expect(filters.offset).toBe(0);
});
it('renders the anonymous actor as plain text (not a button)', async () => {
const anonymousPage: AdminAuditPage = {
...PAGE,
items: [{ ...PAGE.items[0]!, actorIdHash: null }],
};
const { fixture } = setup({ initial: anonymousPage });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const row = root.querySelector<HTMLTableRowElement>('.audit-table tbody tr');
expect(row?.querySelector('.actor-hash--clickable')).toBeNull();
expect(row?.querySelector('.actor-hash')?.textContent?.trim()).toBe('(anonymous)');
});
describe('tabs + charts', () => {
it('defaults to the Table tab on first render — does NOT call the stats endpoint', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const activeTab = root.querySelector('.tab--active');
expect(activeTab?.textContent?.trim()).toBe('Table');
expect(stats).not.toHaveBeenCalled();
});
it('fetches stats when the user opens the Charts tab for the first time', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
const chartsTab = Array.from(root.querySelectorAll<HTMLButtonElement>('.tab')).find(
(b) => b.textContent?.trim() === 'Charts',
);
chartsTab?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
expect(root.querySelector('lib-bar-chart')).not.toBeNull();
expect(root.querySelector('lib-donut-chart')).not.toBeNull();
expect(root.querySelector('lib-stacked-bar-chart')).not.toBeNull();
});
it('does NOT pass pagination knobs to the stats endpoint', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const filters = stats.mock.calls[0]?.[0] as AdminAuditStatsQuery & {
limit?: number;
offset?: number;
};
expect(filters.limit).toBeUndefined();
expect(filters.offset).toBeUndefined();
});
it('does NOT re-fetch stats on pagination (filters unchanged)', async () => {
const { fixture, stats } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
// Switch back to Table and paginate.
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Table')
?.click();
await flush(fixture);
// Open Charts again — cache still valid, no new stats call.
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
});
it('re-fetches stats when filters change and the Charts tab is active', async () => {
const { fixture, stats } = setup();
await flush(fixture);
// Open Charts to load the initial stats.
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(1);
// Component-state mutation: tweak a filter and re-search.
const cmp = fixture.componentInstance as unknown as {
eventType: { set(v: string): void };
search(): Promise<void>;
};
cmp.eventType.set('auth.sign_in');
await cmp.search();
await flush(fixture);
expect(stats).toHaveBeenCalledTimes(2);
});
it('shows the donut centre label with the server-side total (not the per-page count)', async () => {
const { fixture } = setup();
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const donutCenter = root.querySelector('lib-donut-chart .donut-center-label');
expect(donutCenter?.textContent?.trim()).toBe(String(STATS.total));
});
it('shows the empty-state message when the stats endpoint returns 0 events', async () => {
const emptyStats: AdminAuditStats = {
dailyVolume: [],
outcomeBreakdown: [],
eventTypeByDay: [],
total: 0,
};
const { fixture } = setup({ initialStats: emptyStats });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
expect(root.querySelector('lib-bar-chart')).toBeNull();
expect(root.querySelector('#panel-charts .empty')).not.toBeNull();
});
it('renders a permission-aware error when the stats endpoint returns 403', async () => {
const { fixture } = setup({ initialStats: 'error', status: 403 });
await flush(fixture);
const root = fixture.nativeElement as HTMLElement;
Array.from(root.querySelectorAll<HTMLButtonElement>('.tab'))
.find((b) => b.textContent?.trim() === 'Charts')
?.click();
await flush(fixture);
const errMsg = root.querySelector('.status-line--error')?.textContent?.trim();
expect(errMsg).toContain('do not have access');
});
});
});
+3 -129
View File
@@ -1,19 +1,12 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { BarChart, DonutChart, StackedBarChart, semanticStatusColors } from 'shared-charts';
import { environment } from '../../../environments/environment';
import {
AuditEventsService,
type AdminAuditPage,
type AdminAuditQuery,
type AdminAuditStats,
type AdminAuditStatsQuery,
} from './audit-events.service';
/** Tabs shown above the audit content. */
type AuditTab = 'table' | 'charts';
/** Page-size options the SPA offers. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
const PAGE_SIZES = [25, 50, 100, 200] as const;
@@ -37,7 +30,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
*/
@Component({
selector: 'app-audit-page',
imports: [FormsModule, BarChart, DonutChart, StackedBarChart],
imports: [FormsModule],
templateUrl: './audit.html',
styleUrl: './audit.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -47,20 +40,6 @@ export class AuditPage {
protected readonly pageSizes = PAGE_SIZES;
/**
* Outcome → fill mapping for the donut chart. Semantic colours
* resolved from the shared-charts curated set: success = green,
* denied = orange (`warning` intent), failure = red. Keeps the
* legend "obvious without reading" — admins eyeballing the donut
* see the green slice and know it's success without parsing the
* tooltip.
*/
protected readonly outcomeColorMap: Readonly<Record<string, string>> = {
success: semanticStatusColors.success,
failure: semanticStatusColors.error,
denied: semanticStatusColors.warning,
};
// Filter form state. Each field is a separate signal so a change
// to one doesn't churn the others through ngModel's reference
// equality. All start empty — the BFF returns the most recent
@@ -107,53 +86,14 @@ export class AuditPage {
return `${first}${last} of ${p.total}`;
});
// ---------------------------------------------------------------
// Tabs + server-side stats — Charts tab consumes
// `GET /api/admin/audit/stats` (full filtered set, Redis-cached
// 5 min per filter-hash per ADR-0013 §"Reader endpoints"). Fetch
// is lazy: only fires when the Charts tab is opened, then again
// when filters change with the Charts tab active.
// ---------------------------------------------------------------
protected readonly tab = signal<AuditTab>('table');
protected readonly stats = signal<AdminAuditStats | null>(null);
protected readonly statsLoading = signal(false);
protected readonly statsError = signal<string | null>(null);
/** True when the loaded stats are non-empty (total > 0). */
protected readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
/**
* Run a new query with the current filter signals at offset 0.
* Called by the "Apply filters" button + the initial render via
* the template's `@defer (on viewport)` block.
*
* When the user applies new filters, the loaded `stats` becomes
* stale — clear it so the next visit to the Charts tab refetches.
* If the Charts tab is currently active, refetch immediately;
* otherwise wait for the user to open it.
*/
async search(): Promise<void> {
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
/**
* Switch to a tab. On the first switch to `charts` (or any switch
* that follows a filter change), fire the stats fetch — pagination
* within the table doesn't invalidate stats (the filtered set is
* unchanged), so re-opening Charts after paginating is free.
*/
async setTab(tab: AuditTab): Promise<void> {
this.tab.set(tab);
if (tab === 'charts' && this.stats() === null && !this.statsLoading()) {
await this.fetchStats();
}
await this.fetch();
}
/** Page navigation — moves the offset by `limit`, then fetches. */
@@ -179,12 +119,7 @@ export class AuditPage {
this.createdAtFrom.set('');
this.createdAtTo.set('');
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
await this.fetch();
}
/** Format an ISO timestamp into the user's locale for the table. */
@@ -208,36 +143,6 @@ export class AuditPage {
}
}
/**
* Build a Jaeger-UI deep link for the given trace id — the audit
* table's `trace_id` cell becomes a clickable link to the
* trace-explorer per ADR-0012's "join audit and app logs by
* trace_id" promise. Anonymous events (`traceId === null`) don't
* get a link; the cell stays a dash.
*/
protected jaegerUrl(traceId: string): string {
return `${environment.jaegerBaseUrl}/trace/${encodeURIComponent(traceId)}`;
}
/**
* Pivot the table on a single actor: set the `actorIdHash` filter
* to the clicked hash, reset paging to 0, and re-query. Lets an
* investigator click any row's actor cell to "show me everything
* else this actor did". Per-query audit row (`admin.audit.query`)
* is still emitted server-side so the pivot is itself auditable.
*/
async filterByActor(hash: string): Promise<void> {
if (!hash) return;
this.actorIdHash.set(hash);
this.offset.set(0);
this.stats.set(null);
const tasks: Array<Promise<void>> = [this.fetch()];
if (this.tab() === 'charts') {
tasks.push(this.fetchStats());
}
await Promise.all(tasks);
}
private async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
@@ -277,37 +182,6 @@ export class AuditPage {
};
}
/**
* Stats endpoint call. Shares the filter shape with `fetch` minus
* pagination — `buildStatsFilters` strips `limit/offset`.
*/
private async fetchStats(): Promise<void> {
this.statsLoading.set(true);
this.statsError.set(null);
try {
const filters = this.buildStatsFilters();
const stats = await firstValueFrom(this.service.stats(filters));
this.stats.set(stats);
} catch (err) {
const status = errorStatus(err);
this.statsError.set(
status === 403
? 'You do not have access to the audit log on this session.'
: 'Could not load audit statistics. Try again in a moment.',
);
this.stats.set(null);
} finally {
this.statsLoading.set(false);
}
}
private buildStatsFilters(): AdminAuditStatsQuery {
const { limit: _limit, offset: _offset, ...rest } = this.buildFilters();
void _limit;
void _offset;
return rest;
}
ngOnInit(): void {
// Fire the initial unfiltered query so the table renders with
// the most recent events on first paint. Fire-and-forget — the
@@ -1,126 +0,0 @@
<section class="user-scopes">
<header class="user-scopes-header">
<p class="crumbs">
<a routerLink="/users">← Back to users</a>
</p>
<h1 class="title">User scopes</h1>
@if (page(); as p) {
<p class="intro">
Managing scopes for <strong>{{ p.user.displayName }}</strong> ({{ p.user.email ?? '—' }},
<code>{{ p.user.oid }}</code>). Every grant emits <code>admin.scope_granted</code> and every
revoke emits <code>admin.scope_revoked</code> — scope-management is itself auditable per
ADR-0013.
</p>
}
</header>
<div class="status-bar" role="status" aria-live="polite">
@if (loading()) {
<span class="status-line">Loading…</span>
} @else if (error(); as msg) {
<span class="status-line status-line--error">{{ msg }}</span>
}
</div>
@if (page(); as p) {
<section class="scopes-section" aria-labelledby="current-scopes-h">
<h2 id="current-scopes-h" class="section-heading">Current scopes</h2>
@if (p.scopes.length === 0) {
<p class="empty">No scopes granted to this user yet.</p>
} @else {
<div class="table-wrap">
<table class="scopes-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Source</th>
<th scope="col">Granted</th>
<th scope="col">Expires</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (scope of p.scopes; track scope.id) {
<tr>
<td class="cell-scope">
<code>{{ formatScope(scope.kind, scope.value) }}</code>
</td>
<td class="cell-source">{{ scope.source }}</td>
<td class="cell-timestamp">{{ formatTimestamp(scope.createdAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(scope.expiresAt) }}</td>
<td class="cell-actions">
<button
type="button"
class="btn btn--danger"
(click)="revoke(scope.id, formatScope(scope.kind, scope.value))"
>
Revoke
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="grant-section" aria-labelledby="grant-h">
<h2 id="grant-h" class="section-heading">Grant a new scope</h2>
<form class="grant-form" (submit)="$event.preventDefault(); grant()">
<div class="grant-grid">
<label class="field">
<span class="field-label">Kind</span>
<select
name="kind"
[ngModel]="newKind()"
(ngModelChange)="newKind.set($event)"
[disabled]="submitting()"
>
@for (kind of scopeKinds; track kind) {
<option [value]="kind">{{ kind }}</option>
}
</select>
</label>
<label class="field" [class.field--hidden]="!needsValue()">
<span class="field-label">Value</span>
<input
type="text"
name="value"
[ngModel]="newValue()"
(ngModelChange)="newValue.set($event)"
[disabled]="submitting() || !needsValue()"
placeholder="0330800013 / 33 / 75"
[attr.aria-required]="needsValue() ? 'true' : 'false'"
[attr.aria-describedby]="needsValue() ? 'value-hint' : null"
/>
@if (needsValue()) {
<span id="value-hint" class="field-hint">
For etablissement: Structure.code. For delegation: dept code (33, 2A). For region: INSEE
region code (75).
</span>
}
</label>
<label class="field">
<span class="field-label">Expires at (optional)</span>
<input
type="datetime-local"
name="expiresAt"
[ngModel]="newExpiresAt()"
(ngModelChange)="newExpiresAt.set($event)"
[disabled]="submitting()"
/>
</label>
</div>
@if (submitError(); as msg) {
<p class="submit-error" role="alert">{{ msg }}</p>
}
<div class="grant-actions">
<button type="submit" class="btn btn--primary" [disabled]="submitting()">
@if (submitting()) { Granting… } @else { Grant }
</button>
</div>
</form>
</section>
}
</section>
@@ -1,152 +0,0 @@
.user-scopes {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.user-scopes-header {
.crumbs {
margin: 0 0 0.5rem;
font-size: 0.875rem;
}
.title {
margin: 0 0 0.5rem;
}
.intro {
margin: 0;
color: var(--color-text-secondary, #555);
}
}
.status-bar {
min-height: 1.5rem;
}
.status-line--error {
color: var(--color-danger, #b00020);
}
.section-heading {
margin: 0 0 0.75rem;
font-size: 1.125rem;
}
.empty {
font-style: italic;
color: var(--color-text-secondary, #555);
}
.table-wrap {
overflow-x: auto;
}
.scopes-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border, #ddd);
}
.cell-scope code {
font-family: var(--font-mono, monospace);
}
.cell-actions {
text-align: right;
}
}
.grant-section {
background: var(--color-bg-surface, #fafafa);
padding: 1rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.5rem;
}
.grant-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
&.field--hidden {
visibility: hidden;
}
.field-label {
font-weight: 600;
font-size: 0.875rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--color-text-secondary, #555);
}
input,
select {
padding: 0.5rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 0.25rem;
min-height: 44px; /* WCAG 2.2 AA touch target (2.5.5 / 2.5.8). */
}
}
.submit-error {
color: var(--color-danger, #b00020);
margin: 0 0 0.75rem;
}
.grant-actions {
display: flex;
justify-content: flex-end;
}
.btn {
min-height: 44px;
min-width: 44px;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--color-brand-accent-500, #de9415);
color: white;
}
.btn--danger {
background: transparent;
color: var(--color-danger, #b00020);
border-color: currentColor;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -1,92 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import { UserScopesService, type UserScope, type UserScopesPayload } from './user-scopes.service';
const BFF_BASE = 'http://bff.test/api';
const SCOPE: UserScope = {
id: 'scope-1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
};
const PAGE: UserScopesPayload = {
user: {
oid: 'target-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
},
scopes: [SCOPE],
};
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
],
});
return {
service: TestBed.inject(UserScopesService),
http: TestBed.inject(HttpTestingController),
};
}
describe('UserScopesService.list', () => {
it('GETs /admin/users/:oid/scopes and returns the page', async () => {
const { service, http } = setup();
const promise = firstValueFrom(service.list('target-oid'));
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
expect(req.request.method).toBe('GET');
req.flush(PAGE);
await expect(promise).resolves.toEqual(PAGE);
});
it('URL-encodes the oid', async () => {
const { service, http } = setup();
void firstValueFrom(service.list('weird/oid'));
const req = http.expectOne(`${BFF_BASE}/admin/users/weird%2Foid/scopes`);
req.flush(PAGE);
});
});
describe('UserScopesService.grant', () => {
it('POSTs the input body and returns the created scope', async () => {
const { service, http } = setup();
const promise = firstValueFrom(
service.grant('target-oid', {
kind: 'etablissement',
value: '0330800013',
expiresAt: '2026-12-31T23:59:59.000Z',
}),
);
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
kind: 'etablissement',
value: '0330800013',
expiresAt: '2026-12-31T23:59:59.000Z',
});
req.flush(SCOPE);
await expect(promise).resolves.toEqual(SCOPE);
});
});
describe('UserScopesService.revoke', () => {
it('DELETEs the scope by id', async () => {
const { service, http } = setup();
const promise = firstValueFrom(service.revoke('target-oid', 'scope-1'));
const req = http.expectOne(`${BFF_BASE}/admin/users/target-oid/scopes/scope-1`);
expect(req.request.method).toBe('DELETE');
req.flush(null);
await expect(promise).resolves.toBeNull();
});
});
@@ -1,65 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import type { Observable } from 'rxjs';
/**
* One scope row as the SPA sees it — mirror of `UserScopeDto` on
* the BFF side. ISO-string timestamps; the SPA never round-trips
* `Date` instances per the audit-page convention.
*/
export interface UserScope {
readonly id: string;
readonly kind: string;
readonly value: string;
readonly source: string;
readonly createdAt: string;
readonly expiresAt: string | null;
}
export interface UserScopesPayload {
readonly user: {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
};
readonly scopes: ReadonlyArray<UserScope>;
}
export interface GrantUserScopeInput {
readonly kind: string;
readonly value?: string;
readonly expiresAt?: string;
}
/**
* `UserScopesService` — Thin HttpClient wrapper around
* `/api/admin/users/:oid/scopes`. Same shape convention as
* `AdminUsersService` — `providedIn: 'root'` because the single
* consumer is the `UserScopesPayload`.
*/
@Injectable({ providedIn: 'root' })
export class UserScopesService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
list(oid: string): Observable<UserScopesPayload> {
return this.http.get<UserScopesPayload>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
);
}
grant(oid: string, input: GrantUserScopeInput): Observable<UserScope> {
return this.http.post<UserScope>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes`,
input,
);
}
revoke(oid: string, scopeId: string): Observable<void> {
return this.http.delete<void>(
`${this.bffBaseUrl}/admin/users/${encodeURIComponent(oid)}/scopes/${encodeURIComponent(scopeId)}`,
);
}
}
@@ -1,134 +0,0 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AUTH_BFF_BASE_URL } from 'feature-auth';
import {
UserScopesService,
type GrantUserScopeInput,
type UserScope,
type UserScopesPayload,
} from './user-scopes.service';
import { UserScopesPage } from './user-scopes';
const SCOPE: UserScope = {
id: 'scope-1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
};
const PAGE: UserScopesPayload = {
user: {
oid: 'target-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
},
scopes: [SCOPE],
};
function setup(opts?: {
list?: ReturnType<typeof vi.fn>;
grant?: ReturnType<typeof vi.fn>;
revoke?: ReturnType<typeof vi.fn>;
}) {
const list = opts?.list ?? vi.fn().mockReturnValue(of(PAGE));
const grant = opts?.grant ?? vi.fn().mockReturnValue(of(SCOPE));
const revoke = opts?.revoke ?? vi.fn().mockReturnValue(of(undefined));
TestBed.configureTestingModule({
imports: [UserScopesPage],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
{
provide: UserScopesService,
useValue: { list, grant, revoke },
},
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: new Map([['oid', 'target-oid']]) } },
},
],
});
const fixture = TestBed.createComponent(UserScopesPage);
return { fixture, list, grant, revoke };
}
describe('UserScopesPage', () => {
it('loads the scopes for the route param oid on init', async () => {
const { fixture, list } = setup();
fixture.detectChanges();
await fixture.whenStable();
expect(list).toHaveBeenCalledWith('target-oid');
});
it('surfaces a 404 with the seed/sign-in hint', async () => {
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 404 })));
const { fixture } = setup({ list });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = (fixture.nativeElement as HTMLElement).innerHTML;
expect(html).toMatch(/User not found/);
});
it('forbids a 403 with a friendly error', async () => {
const list = vi.fn().mockReturnValue(throwError(() => ({ status: 403 })));
const { fixture } = setup({ list });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const html = (fixture.nativeElement as HTMLElement).innerHTML;
expect(html).toMatch(/admin access/);
});
});
describe('UserScopesPage.grant', () => {
it('sends value for value-bearing kinds + refreshes the list on success', async () => {
let grantPayload: GrantUserScopeInput | null = null;
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
grantPayload = payload;
return of(SCOPE);
});
const list = vi.fn().mockReturnValue(of(PAGE));
const { fixture } = setup({ list, grant });
fixture.detectChanges();
await fixture.whenStable();
const component = fixture.componentInstance as unknown as {
newKind: { set: (v: string) => void };
newValue: { set: (v: string) => void };
grant: () => Promise<void>;
};
component.newKind.set('etablissement');
component.newValue.set('0330800013');
await component.grant();
expect(grant).toHaveBeenCalledTimes(1);
expect(grantPayload).toEqual({ kind: 'etablissement', value: '0330800013' });
// List re-fetched after the grant.
expect(list).toHaveBeenCalledTimes(2);
});
it('omits value for valueless kinds', async () => {
let grantPayload: GrantUserScopeInput | null = null;
const grant = vi.fn((_oid: string, payload: GrantUserScopeInput) => {
grantPayload = payload;
return of(SCOPE);
});
const { fixture } = setup({ grant });
fixture.detectChanges();
await fixture.whenStable();
const component = fixture.componentInstance as unknown as {
newKind: { set: (v: string) => void };
grant: () => Promise<void>;
};
component.newKind.set('unrestricted');
await component.grant();
expect(grantPayload).toEqual({ kind: 'unrestricted' });
});
});
@@ -1,180 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
type OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
UserScopesService,
type GrantUserScopeInput,
type UserScopesPayload,
} from './user-scopes.service';
/**
* The six scope kinds from ADR-0025's catalogue. Hardcoded here so
* the SPA can dropdown them without importing from `shared-auth`
* (which would couple the admin app to the BFF lib path). Drift gate
* lives BFF-side; if the catalogue grows, this list grows in lockstep.
*/
const SCOPE_KINDS = [
'self',
'etablissement',
'delegation',
'region',
'siege',
'unrestricted',
] as const;
const VALUE_BEARING = new Set<string>(['etablissement', 'delegation', 'region']);
/**
* Admin scope-management screen per [ADR-0026 PR 2b](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0026-person-user-portal-data-model.md).
* Lists every UserScope row for the target user, lets the admin grant
* a new one (kind dropdown + value + optional expiresAt), and revoke
* existing ones inline.
*
* Reaches the BFF at `/api/admin/users/:oid/scopes`. The `:oid` URL
* param is the affected user's Entra `oid` — same identifier the
* `/admin/users` list page uses, so a "Manage scopes" link from that
* list lands here without a lookup.
*
* A11y posture (per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)):
* - Form labels associated via `<label>` wrapping or `for`.
* - Live region for status/error messages (`role="status"`).
* - Touch targets respect 44×44 min (button padding in the SCSS).
* - Revoke buttons confirm via `window.confirm` to avoid accidental
* destructive action on a row's keyboard activation.
*/
@Component({
selector: 'app-user-scopes-page',
imports: [FormsModule, RouterLink],
templateUrl: './user-scopes.html',
styleUrl: './user-scopes.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserScopesPage implements OnInit {
private readonly service = inject(UserScopesService);
private readonly route = inject(ActivatedRoute);
protected readonly scopeKinds = SCOPE_KINDS;
protected readonly oid = signal('');
protected readonly page = signal<UserScopesPayload | null>(null);
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
// Form state for the "grant new scope" row.
protected readonly newKind = signal<string>('self');
protected readonly newValue = signal('');
protected readonly newExpiresAt = signal('');
protected readonly submitting = signal(false);
protected readonly submitError = signal<string | null>(null);
protected readonly needsValue = computed(() => VALUE_BEARING.has(this.newKind()));
ngOnInit(): void {
const oid = this.route.snapshot.paramMap.get('oid') ?? '';
this.oid.set(oid);
void this.fetch();
}
protected async fetch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const page = await firstValueFrom(this.service.list(this.oid()));
this.page.set(page);
} catch (err) {
this.error.set(this.translateError(err, 'Could not load scopes for this user.'));
this.page.set(null);
} finally {
this.loading.set(false);
}
}
protected async grant(): Promise<void> {
if (this.submitting()) return;
this.submitting.set(true);
this.submitError.set(null);
const payload: GrantUserScopeInput = {
kind: this.newKind(),
...(this.needsValue() ? { value: this.newValue().trim() } : {}),
...(this.newExpiresAt() ? { expiresAt: toIso(this.newExpiresAt()) } : {}),
};
try {
await firstValueFrom(this.service.grant(this.oid(), payload));
// Reset the form (kind reverts to 'self' as the "least
// privileged" default), then refresh the list to show the new
// row.
this.newKind.set('self');
this.newValue.set('');
this.newExpiresAt.set('');
await this.fetch();
} catch (err) {
this.submitError.set(this.translateError(err, 'Could not grant the scope.'));
} finally {
this.submitting.set(false);
}
}
protected async revoke(scopeId: string, label: string): Promise<void> {
const ok = window.confirm(`Revoke scope "${label}"?`);
if (!ok) return;
try {
await firstValueFrom(this.service.revoke(this.oid(), scopeId));
await this.fetch();
} catch (err) {
this.error.set(this.translateError(err, 'Could not revoke the scope.'));
}
}
protected formatScope(kind: string, value: string): string {
return value === '' ? kind : `${kind}:${value}`;
}
protected formatTimestamp(iso: string | null): string {
if (iso === null) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
private translateError(err: unknown, fallback: string): string {
if (
typeof err === 'object' &&
err !== null &&
'error' in err &&
typeof (err as { error?: unknown }).error === 'object' &&
(err as { error: { message?: unknown } }).error.message !== undefined
) {
const msg = (err as { error: { message?: unknown } }).error.message;
if (typeof msg === 'string') return msg;
if (Array.isArray(msg) && msg.every((m) => typeof m === 'string')) {
return msg.join(' • ');
}
}
const status = errorStatus(err);
if (status === 403) return 'You do not have admin access on this session.';
if (status === 404) return 'User not found — has this persona ever signed in or been seeded?';
return fallback;
}
}
function toIso(localValue: string): string {
const d = new Date(localValue);
return Number.isNaN(d.getTime()) ? localValue : d.toISOString();
}
function errorStatus(err: unknown): number | null {
if (typeof err === 'object' && err !== null && 'status' in err) {
const s = (err as { status: unknown }).status;
return typeof s === 'number' ? s : null;
}
return null;
}
@@ -107,7 +107,6 @@
<th scope="col">First seen</th>
<th scope="col">Last seen</th>
<th scope="col">OID</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@@ -121,15 +120,6 @@
<td class="cell-timestamp">{{ formatTimestamp(user.firstSeenAt) }}</td>
<td class="cell-timestamp">{{ formatTimestamp(user.lastSeenAt) }}</td>
<td class="cell-oid">{{ user.oid }}</td>
<td class="cell-actions">
<a
class="btn btn--secondary"
[routerLink]="['/users', user.oid, 'scopes']"
[attr.aria-label]="'Manage scopes for ' + user.displayName"
>
Manage scopes
</a>
</td>
</tr>
}
</tbody>
@@ -1,5 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import {
AdminUsersService,
@@ -49,7 +48,7 @@ function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }):
});
TestBed.configureTestingModule({
imports: [UsersPage],
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
providers: [{ provide: AdminUsersService, useValue: { query } }],
});
const fixture = TestBed.createComponent(UsersPage);
return { fixture, query };
@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
AdminUsersService,
@@ -27,7 +26,7 @@ const PAGE_SIZES = [25, 50, 100, 200] as const;
*/
@Component({
selector: 'app-users-page',
imports: [FormsModule, RouterLink],
imports: [FormsModule],
templateUrl: './users.html',
styleUrl: './users.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -13,17 +13,13 @@
*/
export const environment = {
/**
* Prefix of the BFF HTTP API. Same value as portal-shell — both
* SPAs talk to the same BFF (per ADR-0020 §"Where does the admin
* app live"). The admin-specific routing happens via the
* `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
* Origin + prefix of the BFF HTTP API. Same value as portal-shell
* — both SPAs talk to the same BFF (per ADR-0020 §"Where does
* the admin app live"). The admin-specific routing happens via
* the `AUTH_PATH_PREFIX` token (`/admin/auth`) provided in
* `app.config.ts`, not by talking to a different host.
*
* Relative path: see portal-shell `environment.ts` for the full
* rationale. Both SPAs use `proxy.conf.js` to proxy `/api/*` to
* the BFF, keeping every call same-origin in the browser.
*/
bffApiBaseUrl: '/api',
bffApiBaseUrl: 'http://localhost:3000/api',
/**
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
@@ -47,16 +43,4 @@ export const environment = {
* not an Angular route.
*/
shellAppUrl: 'http://localhost:4200',
/**
* Base origin of the Jaeger UI — the trace-explorer the admin
* audit-log viewer links each `trace_id` to. Dev points at the
* compose-managed Jaeger from the `observability` profile (see
* `infra/local/dev.compose.yml`). Prod will switch to whatever
* trace backend the future infrastructure ADR picks (Tempo,
* Grafana Cloud, on-prem Jaeger…) via the per-env file replacement.
*
* Trace URLs are built as `${jaegerBaseUrl}/trace/<id>`.
*/
jaegerBaseUrl: 'http://localhost:16686',
};
@@ -21,10 +21,6 @@
<source>Users — APF Portal Admin</source>
<target>Utilisateurs — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.user-scopes.title" datatype="html">
<source>User scopes — APF Portal Admin</source>
<target>Périmètres utilisateur — Administration APF Portal</target>
</trans-unit>
<trans-unit id="route.profile.title" datatype="html">
<source>Profile — APF Portal Admin</source>
<target>Profil — Administration APF Portal</target>
-15
View File
@@ -16,18 +16,3 @@
/* Brand palette tokens — shared with portal-shell. */
@import '../../../libs/shared/tokens/src/brand-tokens.css';
/*
* The admin shell is a "fills the viewport, never scrolls the body"
* layout: <app-root> is locked at height: 100vh, and <main> owns its
* own overflow-y. If anything inside the shell ever pushes content
* past the viewport (a wide chart, a flex sizing bug, a third-party
* iframe), we'd rather clip than show a phantom body scrollbar plus
* the empty space below the footer that comes with it. The element-
* level overflow on <main> still produces a real, scrollable region
* for content that overflows by design.
*/
html,
body {
overflow-y: hidden;
}
-3
View File
@@ -7,9 +7,6 @@
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../../libs/shared/charts/tsconfig.lib.json"
},
{
"path": "../../libs/shared/ui/tsconfig.lib.json"
},
-52
View File
@@ -60,23 +60,6 @@ ENTRA_CLIENT_SECRET=replace_with_real_value
# User portal — `/api/auth/callback` is the OIDC return URL; the
# post-logout URL is where Entra sends the browser after RP-initiated
# logout (typically the SPA landing page).
#
# The four `localhost` values below are the **WSL-native default** —
# browser and BFF on the same host, `http://localhost:*` redirect
# URIs (which Entra accepts as the only exception to its HTTPS rule).
# Leave them here in this file regardless of how the docker `apps`
# profile is being run.
#
# For the ADR-0030 dockerised `apps` profile accessed via a
# hostname (e.g. `https://apf-portal.dev-jg.local:4200/`), do NOT
# edit these values — instead override them at the compose level
# from `infra/local/.env`. Compose's `environment:` block on the
# `portal-bff` service interpolates each of these four vars at
# parse time and wins over `env_file:`, so the BFF in the container
# sees the hostname URIs while native `nx serve` keeps reading
# this file's localhost defaults. See
# `infra/README.md` → "Switching between dev modes" for the
# two-mode toggle.
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
@@ -89,28 +72,6 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
# Authorization model (per ADR-0025). Points at the JSON file that
# maps tenant-private Entra security-group GUIDs to the closed
# catalogue of `apf-role-*` slugs. The BFF loads it at boot through
# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the
# resolver runs empty — sign-in still succeeds but every user gets
# zero functional roles (no `apf-role-*` UI). A WARN is logged at
# boot so an operator can spot the missing config. See
# `infra/test-tenant.entra.example.json` for the schema; copy to
# `infra/test-tenant.entra.json` (gitignored) and fill in the real
# GUIDs from the Entra admin centre.
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
# Test-tenant per-persona Entra `oid` map (per ADR-0026 PR 2).
# Consumed by `apps/portal-bff/prisma/seed.ts` to upsert Person +
# User + UserScope rows for the 19 test personas. Distinct from
# `ENTRA_GROUP_MAP_PATH` which points at the 24 functional-role
# *group* guids. Unset means `prisma db seed` skips the test-tenant
# section (no Person/User/UserScope rows seeded — lazy creation at
# first sign-in still works). See
# `infra/test-tenant.personas.example.json` for the schema.
TEST_TENANT_PERSONAS_PATH=infra/test-tenant.personas.json
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
@@ -245,16 +206,3 @@ CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
# BFF_JWKS_KID — wired
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)
# AI service relay (ADR-0024) — the BFF dials apf-ai-service over
# native gRPC HTTP/2 and bridges chat streams to SSE for the SPA.
# apf-ai-service runs from its own repo (../apf-ai-service); use
# that repo's docker-compose.yml to bring it up locally, then point
# AI_SERVICE_GRPC_ENDPOINT here at the host-published port. No
# JWT/auth on the wire in v1 — the Principal travels in the proto
# body (per ADR-0024 §"Sub-decision 4 — POC unsigned principal").
AI_SERVICE_GRPC_ENDPOINT=localhost:8080
AI_SERVICE_CLIENT_ID=apf-portal-dev
# Set to 'true' in preprod/prod (h2 + TLS via the edge proxy);
# 'false' in dev for h2c against the local apf-ai-service.
AI_SERVICE_GRPC_TLS=false
@@ -1,121 +0,0 @@
-- Organisational hierarchy (per ADR-0027).
--
-- Region → Delegation → Structure. The portal's ADR-0025 scope-axis
-- dereferences these three layers — the BFF guard
-- `principalCoversResource` walks etablissement → delegation → region
-- to decide whether a scope covers a resource. The schema lives in
-- the public schema; ADR-0029 will populate the full APF inventory
-- via the cascade sync, additively into the columns defined here.
--
-- Inline seed at the bottom of this file: just the codes the
-- test tenant exercises (Région Nouvelle-Aquitaine, Délégation
-- Gironde, two médico-social structures + the APF national siège).
-- Superseded — not extended — by ADR-0029's cascade sync once it
-- ships.
-- CreateTable
CREATE TABLE "regions" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "regions_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "delegations" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"region_code" TEXT NOT NULL,
CONSTRAINT "delegations_pkey" PRIMARY KEY ("code")
);
-- Closed-set CHECK on Structure.kind. Mirrors STRUCTURE_KINDS in
-- apps/portal-bff/src/structures/structure-kind.ts. The CI drift gate
-- (scripts/check-catalogue-drift.mjs) asserts the TS constant matches
-- the values used in code; this CHECK constraint provides the
-- equivalent guarantee at the DB layer for any code path that
-- bypasses the type system.
CREATE TABLE "structures" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"finess" TEXT,
"siret" TEXT,
"code_paie" TEXT,
"delegation_code" TEXT,
CONSTRAINT "structures_pkey" PRIMARY KEY ("code"),
CONSTRAINT "structures_kind_check" CHECK (
"kind" IN (
'medico_social',
'antenne',
'dispositif',
'entreprise_adaptee',
'mouvement',
'administratif',
'siege'
)
)
);
-- CreateIndex
CREATE INDEX "delegations_region_code_idx" ON "delegations"("region_code");
-- CreateIndex
CREATE UNIQUE INDEX "structures_finess_key" ON "structures"("finess");
-- CreateIndex
CREATE UNIQUE INDEX "structures_siret_key" ON "structures"("siret");
-- CreateIndex
CREATE UNIQUE INDEX "structures_code_paie_key" ON "structures"("code_paie");
-- CreateIndex
CREATE INDEX "structures_kind_idx" ON "structures"("kind");
-- CreateIndex
CREATE INDEX "structures_delegation_code_idx" ON "structures"("delegation_code");
-- AddForeignKey
ALTER TABLE "delegations" ADD CONSTRAINT "delegations_region_code_fkey"
FOREIGN KEY ("region_code") REFERENCES "regions"("code")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
-- ON DELETE SET NULL because delegationCode is optional in the Prisma
-- schema (Structure.delegation Delegation?). Matches Prisma's default
-- for nullable relations; the drift gate would otherwise flag this
-- on every `prisma migrate dev`.
ALTER TABLE "structures" ADD CONSTRAINT "structures_delegation_code_fkey"
FOREIGN KEY ("delegation_code") REFERENCES "delegations"("code")
ON DELETE SET NULL ON UPDATE CASCADE;
-- ------------------------------------------------------------------
-- Test-tenant reference seed (per ADR-0027 §"Seeding posture").
--
-- Region Nouvelle-Aquitaine + Délégation Gironde + the structures
-- referenced by the 19 personas in notes/test-tenant-role-assignments.md:
-- - 0330800013 (APF Bordeaux, FINESS = code)
-- - 0330800021 (Complexe Mérignac, FINESS = code)
-- - 'siege' (APF national headquarters)
--
-- Cascade-sync (ADR-0029) supersedes this seed entirely when it
-- ships — the cleanup migration responsible for that truncates and
-- repopulates from cascade's authoritative inventory.
-- ------------------------------------------------------------------
INSERT INTO "regions" ("code", "name") VALUES
('75', 'Nouvelle-Aquitaine');
INSERT INTO "delegations" ("code", "name", "region_code") VALUES
('33', 'Gironde', '75');
INSERT INTO "structures" ("code", "name", "kind", "finess", "delegation_code") VALUES
('0330800013', 'APF Bordeaux', 'medico_social', '0330800013', '33'),
('0330800021', 'Complexe Mérignac', 'medico_social', '0330800021', '33');
-- Siège has no delegation parent and no FINESS/SIRET/codePaie (those
-- columns stay NULL — the unique indexes tolerate multiple NULLs).
INSERT INTO "structures" ("code", "name", "kind") VALUES
('siege', 'Siège APF France handicap', 'siege');
@@ -1,19 +0,0 @@
-- Rename `users` -> `user_directory_entries`.
--
-- Prepares for ADR-0026 PR 1 which introduces a NEW `User` model
-- (UUID PK + FK to `Person` + `lastSignInAt`) with materially
-- different semantics. The existing model (ADR-0020 sign-in cache,
-- Entra `oid` as PK) keeps its role but gets a more precise name
-- that won't collide.
--
-- Mechanical refactor only — same columns, same indexes, same
-- constraints. ADR-0026 PR 1 lands the new `users` table with
-- the new semantics.
ALTER TABLE "users" RENAME TO "user_directory_entries";
-- Rename the PK constraint + indexes so they track the new table name.
-- Postgres doesn't auto-rename these on ALTER TABLE RENAME.
ALTER INDEX "users_pkey" RENAME TO "user_directory_entries_pkey";
ALTER INDEX "users_last_seen_at_idx" RENAME TO "user_directory_entries_last_seen_at_idx";
ALTER INDEX "users_username_idx" RENAME TO "user_directory_entries_username_idx";
@@ -1,84 +0,0 @@
-- Identity model (per ADR-0026 PR 1).
--
-- Person golden record + User portal-account overlay (1-to-0-or-1)
-- + UserScope (the table behind the ADR-0025 scope axis). Distinct
-- from `user_directory_entries` (ADR-0020 sign-in cache, renamed in
-- the previous migration) — those keep their role; these are new.
--
-- No seed data — the test-tenant scope rows ship in ADR-0026 PR 2
-- via `prisma/seed.ts`, after this schema is in place.
-- CreateTable
CREATE TABLE "persons" (
"id" UUID NOT NULL,
"first_name" TEXT NOT NULL,
"last_name" TEXT NOT NULL,
"email" TEXT,
"source" TEXT NOT NULL,
"external_id" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "persons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"person_id" UUID NOT NULL,
"entra_oid" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"last_sign_in_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_scopes" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"kind" TEXT NOT NULL,
"value" TEXT NOT NULL DEFAULT '',
"source" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMPTZ(6),
CONSTRAINT "user_scopes_pkey" PRIMARY KEY ("id")
);
-- Person indexes
CREATE INDEX "persons_source_idx" ON "persons"("source");
CREATE INDEX "persons_external_id_idx" ON "persons"("external_id");
CREATE INDEX "persons_email_idx" ON "persons"("email");
-- User indexes — both unique constraints back btree indexes.
CREATE UNIQUE INDEX "users_person_id_key" ON "users"("person_id");
CREATE UNIQUE INDEX "users_entra_oid_key" ON "users"("entra_oid");
-- UserScope indexes — the unique constraint backs the composite,
-- the plain index supports the read-by-userId hot path on sign-in.
CREATE UNIQUE INDEX "user_scopes_user_id_kind_value_key" ON "user_scopes"("user_id", "kind", "value");
CREATE INDEX "user_scopes_user_id_idx" ON "user_scopes"("user_id");
-- Foreign keys.
--
-- User.person_id is REQUIRED → ON DELETE RESTRICT (Prisma default for
-- required relations). Removing a Person with a portal User must be
-- an explicit two-step in the admin path; never an accidental cascade.
ALTER TABLE "users" ADD CONSTRAINT "users_person_id_fkey"
FOREIGN KEY ("person_id") REFERENCES "persons"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- UserScope.user_id has explicit `onDelete: Cascade` in the Prisma
-- schema — revoking a User wipes their scope rows in one delete,
-- which is the desired admin-UI semantic (deactivating an account
-- should not leave dangling authorisation rows).
ALTER TABLE "user_scopes" ADD CONSTRAINT "user_scopes_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+2 -208
View File
@@ -67,12 +67,6 @@ enum AuditOutcome {
// (combined with the salted hash) — never the BFF's primary actor
// identifier elsewhere.
//
// **Distinct from the upcoming ADR-0026 `User` model.** That one
// (UUID PK + FK to `Person` + lazy-created at OIDC callback) is the
// portal-account overlay on a `Person` golden record. This
// `UserDirectoryEntry` is the ADR-0020 sign-in cache — different
// semantics, kept apart so neither concept overloads the other.
//
// **No PII redaction on read.** Per ADR-0013 the audit module
// hashes the actor id to defend against an audit-log dump leaking
// who-did-what. This table is the *deliberate* PII storage: an
@@ -80,7 +74,7 @@ enum AuditOutcome {
// usernames. The trust boundary is the admin role gate
// (ADR-0020 §"Auth — `admin` role claim").
model UserDirectoryEntry {
model User {
// Entra `oid` — stable per-user identifier inside the tenant.
// Used as the natural primary key. Per-tenant uniqueness is
// sufficient: the dual-audience design (ADR-0008) currently
@@ -100,212 +94,12 @@ model UserDirectoryEntry {
// without scanning audit.events.
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
@@map("user_directory_entries")
@@map("users")
@@schema("public")
@@index([lastSeenAt(sort: Desc)])
@@index([username])
}
// ============================================================
// Identity model (per ADR-0026)
// ============================================================
//
// `Person` golden record + `User` portal-account overlay (one-to-zero-
// or-one). Distinct from `UserDirectoryEntry` above — that one is the
// ADR-0020 sign-in cache keyed on Entra `oid`; these are the portal's
// stable identity model keyed on UUIDs and form the basis for the
// `@RequireScope` Prisma resolver landing in ADR-0026 PR 2.
//
// `Person` exists whether or not the human ever signs in (workforce
// pre-provisioning, dossier bénéficiaires, alumni). `User` is the
// portal-access overlay, lazy-created at first OIDC callback by
// `PersonAndUserProvisioner.ensureUser`. `UserScope` backs the ADR-0025
// scope axis with opaque `value` strings referencing ADR-0027's
// `Structure.code` / `Delegation.code` / `Region.code` — no FK at the
// DB level so historical scope rows survive structure decommissioning.
model Person {
id String @id @default(uuid()) @db.Uuid
// PII — firstName + lastName are subject to ADR-0013 redaction rules
// when emitted to logs.
firstName String @map("first_name")
lastName String @map("last_name")
// Primary contact email. Indexed for operator lookup but NOT unique
// — two distinct humans genuinely can share emails (shared family
// alias, generic info@ at a small partner organisation, error in an
// upstream feed). ADR-0029's reconciliation flow surfaces candidate
// duplicates for operator confirmation rather than auto-merging.
email String?
// Closed-set catalogue, drift-gated. See
// `apps/portal-bff/src/users/person-source.ts` for the legal values.
// v1: 'self-signin' / 'admin-ui' / 'seed'. ADR-0029 adds 'pleiades'
// and 'acteurs-plus'.
source String
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
// Nullable in v1 — populated by ADR-0029's syncs.
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Back-ref. NULL when the Person has no portal account (workforce
// pre-provisioning, dossier bénéficiaire, alumni).
user User?
@@map("persons")
@@schema("public")
@@index([source])
@@index([externalId])
@@index([email])
}
model User {
id String @id @default(uuid()) @db.Uuid
// 1-to-1 with Person. The unique constraint on personId enforces
// "at most one User per Person"; back-ref `Person.user` is the
// other half.
personId String @unique @map("person_id") @db.Uuid
person Person @relation(fields: [personId], references: [id])
// Entra object id — stable per-user identifier inside the tenant.
// Unique because two separate Person+User pairs cannot share an
// Entra identity.
entraOid String @unique @map("entra_oid")
// Entra tenant id. Stored alongside the oid so a future dual-
// audience activation (ADR-0008) can disambiguate.
tenantId String @map("tenant_id")
// Refreshed by `PersonAndUserProvisioner.ensureUser` on every
// sign-in. Surfaced in the future /admin/users/:id screen.
lastSignInAt DateTime? @map("last_sign_in_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
scopes UserScope[]
@@map("users")
@@schema("public")
}
model UserScope {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
// CASCADE so revoking a User wipes their scope rows in one delete.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// One of the six ADR-0025 scope kinds: 'self' / 'etablissement' /
// 'delegation' / 'region' / 'siege' / 'unrestricted'. Not pulled
// from a Prisma enum because the value's semantics belong to the
// `shared-auth` catalogue, not the database; the drift gate
// already enforces the closed set at the call sites.
kind String
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
// the value is a Structure.code; for 'delegation' it is a
// Delegation.code; for 'region' it is a Region.code. Empty string
// for 'self' / 'siege' / 'unrestricted'. NO FK to ADR-0027 tables —
// historical scope rows survive structure decommissioning; the
// admin-UI write path (ADR-0026 PR 2) validates at insert time.
value String @default("")
// Provenance, same catalogue posture as Person.source. v1:
// 'admin-ui' / 'seed'. ADR-0029 adds upstream-sync values and a
// reconciliation policy.
source String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// When non-null and past, the row is ignored at sign-in. Supports
// interim-director-for-two-months patterns and admin UI scope
// revocations.
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
@@map("user_scopes")
@@schema("public")
@@unique([userId, kind, value])
@@index([userId])
}
// ============================================================
// Organisational hierarchy (per ADR-0027)
// ============================================================
//
// Region → Delegation → Structure. The portal's scope-axis
// dereferences these three layers — the BFF guard
// `principalCoversResource` walks etablissement → delegation →
// region to decide whether a scope covers a resource (per
// ADR-0025).
//
// Population in v1: a small inline seed in the
// `add_org_hierarchy` migration (the codes the test tenant
// exercises). The full APF inventory ships with ADR-0029's
// cascade sync, which writes additively into these columns —
// no schema churn at sync time.
model Region {
// INSEE region code (2 digits — '75' Nouvelle-Aquitaine,
// '11' Île-de-France). Externally meaningful and stable
// across reorgs; doubles as primary key.
code String @id
name String
delegations Delegation[]
@@map("regions")
@@schema("public")
}
model Delegation {
// French department code (2-3 chars — '33' Gironde, '2A',
// '971'). Same rationale as Region.code.
code String @id
name String
regionCode String @map("region_code")
region Region @relation(fields: [regionCode], references: [code])
structures Structure[]
@@map("delegations")
@@schema("public")
@@index([regionCode])
}
model Structure {
// Portal-internal stable code, externally meaningful. For
// medico-social structures: code = FINESS (9 digits). For
// non-medico-social: APF-internal slug ('siege',
// 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type
// level; matching is string equality, not parsing.
code String @id
name String
// Closed set, drift-gated. Legal values are tracked in
// `apps/portal-bff/src/structures/structure-kind.ts` and
// mirrored by a Postgres CHECK constraint on this column
// (see the migration). `scripts/check-catalogue-drift.mjs`
// asserts the TS constant matches the values used in code.
kind String
// FINESS (9 digits). NULL for non-medico-social structures.
// Unique when present. Cascade's StructureSourceFiness is
// the long-term authoritative carrier; the portal denormalises
// it inline here for v1 scope-axis checks. ADR-0029's sync
// owns the write path.
finess String? @unique
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure
// is not SIRENE-registered (most antennes, dispositifs).
// Unique when present.
siret String? @unique
// Pléiades payroll code (6 chars). NULL in v1 — populated by
// ADR-0029's Pléiades sync once it ships.
codePaie String? @unique @map("code_paie")
// Parent delegation. NULL for structures not attached to one
// (siège, mouvement national, …).
delegationCode String? @map("delegation_code")
delegation Delegation? @relation(fields: [delegationCode], references: [code])
@@map("structures")
@@schema("public")
@@index([kind])
@@index([delegationCode])
}
model AuditEvent {
id String @id @default(uuid()) @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
-291
View File
@@ -1,291 +0,0 @@
/**
* Test-tenant seed per ADR-0026 PR 2.
*
* Run via:
* pnpm exec prisma db seed
*
* Idempotent: re-running this script preserves existing rows. The
* checks for "row already there" run against the unique constraints:
* - User.entraOid (skipping the cold path of
* PersonAndUserProvisioner if the row exists),
* - UserScope.(userId, kind, value).
*
* The seed creates Person + User + UserScope rows for the 19 personas
* provisioned in the `apfrd.onmicrosoft.com` test tenant. The
* persona matrix below is transcribed from
* `notes/test-tenant-role-assignments.md` (gitignored) — that file
* remains the human-readable reference; this constant is the source
* of truth for the seed.
*
* **Test-tenant Entra `oid`s** are read from a JSON file pointed at
* by `TEST_TENANT_PERSONAS_PATH` (or `infra/test-tenant.personas.json`
* by default). The file is gitignored — copy
* `infra/test-tenant.personas.example.json` and fill in real values
* from the Entra admin centre. Missing file → seed skips the
* test-tenant section cleanly (no error, just a log line).
*
* **`Person.source = 'seed'`** for every row created here, per the
* PERSON_SOURCES catalogue. Differentiates these rows from
* 'self-signin' (lazy-created at first sign-in) and 'admin-ui' (future
* scope-seeding admin screen, ADR-0026 PR 2b).
*
* **Privileges + functional roles are NOT seeded** — those come from
* Entra at sign-in (Entra `roles` claim + `groups` claim resolved by
* `EntraGroupToRoleResolver`). Only scopes are portal-side data.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { PrismaClient } from '@prisma/client';
interface PersonaSeed {
readonly slug: string;
readonly email: string;
readonly displayName: string;
/**
* Scope tuples per `notes/test-tenant-role-assignments.md`. The
* `value` is omitted for valueless kinds (self / siege /
* unrestricted) — the seed writes an empty string to the DB
* `value` column, matching the column's default.
*/
readonly scopes: ReadonlyArray<{ readonly kind: string; readonly value?: string }>;
}
const TENANT_DOMAIN = 'apfrd.onmicrosoft.com';
const PERSONAS: ReadonlyArray<PersonaSeed> = [
{
slug: 'admin',
email: `admin@${TENANT_DOMAIN}`,
displayName: 'Admin User',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'directeur-bordeaux',
email: `directeur-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Directeur Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'directeur-complexe',
email: `directeur-complexe@${TENANT_DOMAIN}`,
displayName: 'Directeur Complexe',
scopes: [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'etablissement', value: '0330800021' },
],
},
{
slug: 'rh-aquitaine',
email: `rh-aquitaine@${TENANT_DOMAIN}`,
displayName: 'RH Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'rh-siege',
email: `rh-siege@${TENANT_DOMAIN}`,
displayName: 'RH Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'collaborateur-simple',
email: `collaborateur-simple@${TENANT_DOMAIN}`,
displayName: 'Collaborateur Simple',
scopes: [{ kind: 'self' }],
},
{
slug: 'tresorier-bordeaux',
email: `tresorier-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Trésorier Bordeaux',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'dpo',
email: `dpo@${TENANT_DOMAIN}`,
displayName: 'DPO',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'it',
email: `it@${TENANT_DOMAIN}`,
displayName: 'IT',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'benevole-aquitaine',
email: `benevole-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Bénévole Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'chef-equipe-bordeaux',
email: `chef-equipe-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Chef Equipe Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'chef-service-bordeaux',
email: `chef-service-bordeaux@${TENANT_DOMAIN}`,
displayName: 'Chef Service Bordeaux',
scopes: [{ kind: 'etablissement', value: '0330800013' }],
},
{
slug: 'directeur-territorial-aquitaine',
email: `directeur-territorial-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Directeur Territorial Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'juriste-siege',
email: `juriste-siege@${TENANT_DOMAIN}`,
displayName: 'Juriste Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'rssi',
email: `rssi@${TENANT_DOMAIN}`,
displayName: 'RSSI',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'communication-siege',
email: `communication-siege@${TENANT_DOMAIN}`,
displayName: 'Communication Siège',
scopes: [{ kind: 'unrestricted' }],
},
{
slug: 'elu-ca-national',
email: `elu-ca-national@${TENANT_DOMAIN}`,
displayName: 'Elu CA National',
scopes: [{ kind: 'siege' }],
},
{
slug: 'president-cd-aquitaine',
email: `president-cd-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Président CD Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
{
slug: 'secretaire-cd-aquitaine',
email: `secretaire-cd-aquitaine@${TENANT_DOMAIN}`,
displayName: 'Secrétaire CD Aquitaine',
scopes: [{ kind: 'delegation', value: '33' }],
},
];
async function main(): Promise<void> {
const prisma = new PrismaClient();
try {
await seedTestTenant(prisma);
} finally {
await prisma.$disconnect();
}
}
async function seedTestTenant(prisma: PrismaClient): Promise<void> {
const personasPath = resolve(
process.env['TEST_TENANT_PERSONAS_PATH'] ?? 'infra/test-tenant.personas.json',
);
if (!existsSync(personasPath)) {
console.log(
`[seed] TEST_TENANT_PERSONAS_PATH (${personasPath}) not found — skipping test-tenant seed.`,
);
return;
}
let entraOidBySlug: Record<string, string>;
try {
const raw = readFileSync(personasPath, 'utf8');
entraOidBySlug = JSON.parse(raw) as Record<string, string>;
} catch (err) {
console.error(
`[seed] could not parse ${personasPath}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const tenantId = process.env['ENTRA_TENANT_ID'] ?? TENANT_DOMAIN;
const counts = { personsCreated: 0, usersCreated: 0, scopesCreated: 0, skipped: 0 };
for (const persona of PERSONAS) {
const entraOid = entraOidBySlug[persona.slug];
if (entraOid === undefined || typeof entraOid !== 'string' || entraOid === '') {
console.warn(`[seed] persona "${persona.slug}" missing oid in ${personasPath} — skipping.`);
counts.skipped += 1;
continue;
}
const { userId, created } = await ensurePersonAndUser(prisma, persona, entraOid, tenantId);
if (created) {
counts.personsCreated += 1;
counts.usersCreated += 1;
}
for (const scope of persona.scopes) {
const seededNew = await ensureUserScope(prisma, userId, scope.kind, scope.value ?? '');
if (seededNew) counts.scopesCreated += 1;
}
}
console.log(
`[seed] test-tenant complete — created ${counts.personsCreated} Person + ${counts.usersCreated} User + ${counts.scopesCreated} UserScope rows; skipped ${counts.skipped} personas (missing oid).`,
);
}
async function ensurePersonAndUser(
prisma: PrismaClient,
persona: PersonaSeed,
entraOid: string,
tenantId: string,
): Promise<{ userId: string; personId: string; created: boolean }> {
const existing = await prisma.user.findUnique({
where: { entraOid },
select: { id: true, personId: true },
});
if (existing) {
return { userId: existing.id, personId: existing.personId, created: false };
}
const [firstName, lastName] = splitDisplayName(persona.displayName);
const created = await prisma.user.create({
data: {
entraOid,
tenantId,
person: {
create: {
firstName,
lastName,
email: persona.email,
source: 'seed',
},
},
},
select: { id: true, personId: true },
});
return { userId: created.id, personId: created.personId, created: true };
}
async function ensureUserScope(
prisma: PrismaClient,
userId: string,
kind: string,
value: string,
): Promise<boolean> {
const existing = await prisma.userScope.findUnique({
where: { userId_kind_value: { userId, kind, value } },
});
if (existing) return false;
await prisma.userScope.create({
data: { userId, kind, value, source: 'seed' },
});
return true;
}
function splitDisplayName(displayName: string): [string, string] {
const trimmed = displayName.trim();
const space = trimmed.indexOf(' ');
if (space < 0) return [trimmed, ''];
return [trimmed.slice(0, space), trimmed.slice(space + 1).trim()];
}
main().catch((err: unknown) => {
console.error('[seed] failed:', err);
process.exit(1);
});
@@ -2,8 +2,6 @@ import type { Request } from 'express';
import { AdminAuditController } from './admin-audit.controller';
import type { AdminAuditQueryDto } from './audit-query.dto';
import type { AuditReader, AdminAuditPage } from './audit-reader.service';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
import type { AuditStatsReader, AdminAuditStats } from './audit-stats.service';
import type { AuditWriter } from '../audit/audit.service';
const PAGE: AdminAuditPage = {
@@ -29,30 +27,18 @@ function makeReq(user?: { oid: string }): Request {
return { session: user !== undefined ? { user } : {} } as unknown as Request;
}
const STATS: AdminAuditStats = {
dailyVolume: [{ day: '2026-05-13', count: 1 }],
outcomeBreakdown: [{ outcome: 'success', count: 1 }],
eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 1 }],
total: 1,
};
function makeFixture() {
const auditReader = {
findEvents: jest.fn().mockResolvedValue(PAGE),
};
const auditStatsReader = {
getStats: jest.fn().mockResolvedValue(STATS),
};
const auditWriter = {
adminAuditQuery: jest.fn().mockResolvedValue(undefined),
adminAuditStatsQuery: jest.fn().mockResolvedValue(undefined),
};
const controller = new AdminAuditController(
auditReader as unknown as AuditReader,
auditStatsReader as unknown as AuditStatsReader,
auditWriter as unknown as AuditWriter,
);
return { controller, auditReader, auditStatsReader, auditWriter };
return { controller, auditReader, auditWriter };
}
describe('AdminAuditController.list', () => {
@@ -112,51 +98,3 @@ describe('AdminAuditController.list', () => {
).rejects.toThrow('audit_writer denied');
});
});
describe('AdminAuditController.stats', () => {
it('returns the aggregated stats from AuditStatsReader', async () => {
const { controller } = makeFixture();
const result = await controller.stats(
makeReq({ oid: 'admin-oid' }),
{} as AdminAuditStatsQueryDto,
);
expect(result).toBe(STATS);
});
it('forwards the validated DTO to AuditStatsReader as-is', async () => {
const { controller, auditStatsReader } = makeFixture();
const filters: AdminAuditStatsQueryDto = {
eventType: 'auth.sign_in',
audience: 'workforce',
createdAtFrom: '2026-05-01T00:00:00Z',
};
await controller.stats(makeReq({ oid: 'admin-oid' }), filters);
expect(auditStatsReader.getStats).toHaveBeenCalledWith(filters);
});
it('emits admin.audit.stats.query with the filters + total as the deterrent signal', async () => {
const { controller, auditWriter } = makeFixture();
const filters: AdminAuditStatsQueryDto = { outcome: 'denied' };
await controller.stats(makeReq({ oid: 'admin-oid' }), filters);
expect(auditWriter.adminAuditStatsQuery).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
filters: { outcome: 'denied' },
total: STATS.total,
});
});
it('still returns stats when there is no session.user (defensive)', async () => {
const { controller, auditWriter } = makeFixture();
const result = await controller.stats(makeReq(), {} as AdminAuditStatsQueryDto);
expect(result).toBe(STATS);
expect(auditWriter.adminAuditStatsQuery).not.toHaveBeenCalled();
});
it('propagates audit write failures (same blocking posture as list)', async () => {
const { controller, auditWriter } = makeFixture();
auditWriter.adminAuditStatsQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
await expect(
controller.stats(makeReq({ oid: 'admin-oid' }), {} as AdminAuditStatsQueryDto),
).rejects.toThrow('audit_writer denied');
});
});
@@ -4,8 +4,6 @@ import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { AdminAuditQueryDto } from './audit-query.dto';
import { AuditReader, type AdminAuditPage } from './audit-reader.service';
import { AdminAuditStatsQueryDto } from './audit-stats.dto';
import { AuditStatsReader, type AdminAuditStats } from './audit-stats.service';
import { RequireAdmin } from './require-admin.decorator';
/**
@@ -37,7 +35,6 @@ import { RequireAdmin } from './require-admin.decorator';
export class AdminAuditController {
constructor(
private readonly auditReader: AuditReader,
private readonly auditStats: AuditStatsReader,
private readonly audit: AuditWriter,
) {}
@@ -64,38 +61,4 @@ export class AdminAuditController {
return page;
}
/**
* `GET /api/admin/audit/stats` — server-side aggregations over
* the full filtered set (no pagination). Powers the "Charts" tab
* of the audit viewer per the chantier brief.
*
* Emits `admin.audit.stats.query` per call — same fishing-
* expedition deterrent posture as `list` above. Results are
* Redis-cached for 5 minutes inside `AuditStatsReader` to absorb
* the cost of repeated identical queries (the SPA hits this on
* each tab switch + filter apply).
*/
@ApiOperation({
summary:
'Aggregated audit stats (dailyVolume, outcomeBreakdown, eventTypeByDay) for the filtered set',
})
@Get('stats')
async stats(
@Req() req: Request,
@Query() filters: AdminAuditStatsQueryDto,
): Promise<AdminAuditStats> {
const result = await this.auditStats.getStats(filters);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminAuditStatsQuery({
actor: { oid: actorOid },
filters: { ...filters },
total: result.total,
});
}
return result;
}
}
@@ -1,6 +1,5 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';
import type { Principal } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
@@ -13,7 +12,6 @@ interface SessionStub {
amr: readonly string[];
roles: readonly string[];
};
principal?: Principal;
}
function makeRequest(opts: {
@@ -40,22 +38,6 @@ function makeAuditStub(): jest.Mocked<Pick<AuditWriter, 'adminAccessDenied'>> {
};
}
function principalWithPrivileges(privileges: readonly string[]): Principal {
return {
user: {
id: 'user-oid',
personId: 'user-oid',
entraOid: 'user-oid',
tenantId: 't',
displayName: 'Jane',
},
privileges: privileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
describe('AdminRoleGuard', () => {
it('throws 401 when the request has no session at all', async () => {
const audit = makeAuditStub();
@@ -65,7 +47,7 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 401 when the session exists but no principal nor legacy user is bound', async () => {
it('throws 401 when the session exists but no user is bound to it', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: {} }));
@@ -73,12 +55,21 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('throws 403 + records admin.access_denied when the principal has no privileges', async () => {
it('throws 403 + records admin.access_denied when the user has no roles at all', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: { principal: principalWithPrivileges([]) },
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
method: 'GET',
originalUrl: '/api/admin/me',
}),
@@ -91,12 +82,21 @@ describe('AdminRoleGuard', () => {
});
});
it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => {
it('throws 403 + records admin.access_denied when the user has unrelated roles only', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: { principal: principalWithPrivileges(['Portal.Auditor']) },
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: ['editor', 'auditor'],
},
},
method: 'POST',
originalUrl: '/api/admin/cms/pages',
}),
@@ -105,27 +105,45 @@ describe('AdminRoleGuard', () => {
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'POST /api/admin/cms/pages',
rolesHeld: ['Portal.Auditor'],
rolesHeld: ['editor', 'auditor'],
});
});
it('returns true and does not audit when the principal carries Portal.Admin', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several privileges', async () => {
it('returns true and does not audit when the user has the admin role', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']),
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('returns true when admin is one of several roles', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: ['editor', ADMIN_ROLE, 'auditor'],
},
},
}),
);
@@ -137,63 +155,24 @@ describe('AdminRoleGuard', () => {
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
};
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } }));
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: [],
},
},
}),
);
// The 403 path goes through audit first; if audit throws, the
// guard surfaces the underlying error rather than the
// ForbiddenException — the caller sees a 500 and the audit
// invariant is preserved.
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
});
describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => {
it('synthesises a principal from session.user when session.principal is absent', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: [ADMIN_ROLE, 'Other.Role'],
},
},
}),
);
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
});
it('denies a legacy session whose roles claim carries no Portal.* value', async () => {
const audit = makeAuditStub();
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
const ctx = makeContext(
makeRequest({
session: {
user: {
oid: 'user-oid',
tid: 't',
username: 'jane@example',
displayName: 'Jane',
amr: ['pwd'],
roles: ['editor', 'auditor'],
},
},
}),
);
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
// The legacy bridge filters the raw `roles` claim down to
// `Portal.*` values for the audit row's `rolesHeld` field —
// `editor` and `auditor` are not privileges, so the held
// list is empty.
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/admin/me',
rolesHeld: [],
});
});
});
});
+17 -25
View File
@@ -7,7 +7,6 @@ import {
} from '@nestjs/common';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from '../auth/principal-extractor';
/**
* Single Entra app role that gates the entire `/api/admin/*` surface
@@ -21,11 +20,6 @@ export const ADMIN_ROLE = 'Portal.Admin';
/**
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
*
* Reads from `principal.privileges` per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md)
* — the migration thinned the implementation but kept the
* `@RequireAdmin()` public API and the `admin.access_denied`
* audit event type unchanged.
*
* Contract
* --------
* - **No session → 401.** The user is not authenticated at all; the
@@ -34,21 +28,19 @@ export const ADMIN_ROLE = 'Portal.Admin';
* unauthenticated 401 is normal traffic the absolute-timeout
* middleware would surface anyway.
*
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
* The user *is* authenticated; they just are not authorised
* for admin. This is the privilege-escalation attempt audit
* signal — every denial lands in `audit.events` with
* `outcome=denied`, the attempted route as `subject`, and the
* `Portal.*` privileges the principal did hold in the payload's
* `rolesHeld` field (kept named `rolesHeld` for backward
* compatibility with the existing audit shape; the values are
* privileges per ADR-0025's renaming of the axis). Per
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
* *is* authenticated; they just are not authorised for admin.
* This is the privilege-escalation attempt audit signal — every
* denial lands in `audit.events` with `outcome=denied`, the
* attempted route as `subject`, and the roles the user did hold
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
* action — if the audit write fails, the request fails too
* (consistent with the existing audit call sites in
* `AuthController`).
*
* - **Session with `Portal.Admin` privilege → pass through.**
* Downstream controllers see `req.session.user` *and*
* `req.session.principal` populated and can rely on the gate
* having happened.
* - **Session with `Portal.Admin` role → pass through.** Downstream
* controllers see `req.session.user` populated and can rely on
* the role check having happened.
*/
@Injectable()
export class AdminRoleGuard implements CanActivate {
@@ -56,17 +48,17 @@ export class AdminRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
const user = req.session?.user;
if (principal === null) {
if (!user) {
throw new UnauthorizedException();
}
if (!principal.privileges.includes(ADMIN_ROLE)) {
if (!user.roles.includes(ADMIN_ROLE)) {
await this.audit.adminAccessDenied({
actor: { oid: principal.user.entraOid },
actor: { oid: user.oid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
rolesHeld: [...principal.privileges],
rolesHeld: user.roles,
});
throw new ForbiddenException();
}
@@ -3,7 +3,7 @@ import { PrismaService } from 'nestjs-prisma';
import { AdminUsersReader } from './admin-users-reader.service';
interface MockPrisma {
userDirectoryEntry: {
user: {
count: jest.Mock;
findMany: jest.Mock;
};
@@ -14,7 +14,7 @@ function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma {
const count = jest.fn().mockResolvedValue(opts?.count ?? 0);
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
return {
userDirectoryEntry: { count, findMany },
user: { count, findMany },
// The reader calls $transaction with the promises returned by
// `count()` + `findMany()` — the operations have already fired
// by the time $transaction sees them. The mock just resolves
@@ -48,8 +48,8 @@ describe('AdminUsersReader.findUsers', () => {
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.userDirectoryEntry.count).toHaveBeenCalledTimes(1);
expect(prisma.userDirectoryEntry.findMany).toHaveBeenCalledTimes(1);
expect(prisma.user.count).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).toHaveBeenCalledTimes(1);
expect(page.total).toBe(5);
expect(page.items).toHaveLength(1);
});
@@ -73,7 +73,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const findManyArgs = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as {
orderBy: ReadonlyArray<Record<string, string>>;
};
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
@@ -83,7 +83,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ username: 'jane' });
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
where: { username?: { startsWith?: string } };
};
expect(args.where.username).toEqual({ startsWith: 'jane' });
@@ -93,7 +93,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ displayName: 'doe' });
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
where: { displayName?: { contains?: string; mode?: string } };
};
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
@@ -106,7 +106,7 @@ describe('AdminUsersReader.findUsers', () => {
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
lastSeenAtTo: '2026-05-14T23:59:59.999Z',
});
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
};
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
@@ -119,7 +119,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
const page = await reader.findUsers({});
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
take: number;
skip: number;
};
@@ -133,7 +133,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({ limit: 1000 });
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { take: number };
const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number };
expect(args.take).toBe(200);
});
@@ -141,7 +141,7 @@ describe('AdminUsersReader.findUsers', () => {
const prisma = buildPrisma();
const reader = await createSubject(prisma);
await reader.findUsers({});
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: object };
const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object };
expect(args.where).toEqual({});
});
});
@@ -4,9 +4,9 @@ import { PrismaService } from 'nestjs-prisma';
import { DEFAULT_LIMIT, MAX_LIMIT, type AdminUsersQueryDto } from './users-query.dto';
/**
* SPA-facing projection of a row from `public.user_directory_entries`.
* Mirrors the Prisma model but exposes ISO-string timestamps so the
* SPA never has to know about `Date` serialisation conventions.
* SPA-facing projection of a row from `public.users`. Mirrors the
* Prisma model but exposes ISO-string timestamps so the SPA never
* has to know about `Date` serialisation conventions.
*/
export interface AdminUserDto {
readonly oid: string;
@@ -26,15 +26,14 @@ export interface AdminUsersPage {
}
/**
* `AdminUsersReader` — query side of the
* `public.user_directory_entries` directory per ADR-0020 §"v1 scope
* — User list (read-only)". Drives the `GET /api/admin/users` admin
* endpoint.
* `AdminUsersReader` — query side of the `public.users` directory
* per ADR-0020 §"v1 scope — User list (read-only)". Drives the
* `GET /api/admin/users` admin endpoint.
*
* Uses Prisma's typed client directly — unlike `AuditReader`, no
* `SET LOCAL ROLE` is needed because `public.user_directory_entries`
* has no role-based privilege gate (it's a regular business table;
* the trust boundary is the `@RequireAdmin` guard on the controller).
* `SET LOCAL ROLE` is needed because `public.users` has no
* role-based privilege gate (it's a regular business table; the
* trust boundary is the `@RequireAdmin` guard on the controller).
*
* Default order: `last_seen_at DESC` — the "most recently active"
* sort the admin UI most often wants. Falls back to `oid ASC` as
@@ -56,8 +55,8 @@ export class AdminUsersReader {
// otherwise produce an off-by-one between the count and the
// items.
const [total, items] = await this.prisma.$transaction([
this.prisma.userDirectoryEntry.count({ where }),
this.prisma.userDirectoryEntry.findMany({
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }],
take: limit,
@@ -74,8 +73,8 @@ export class AdminUsersReader {
}
}
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput {
const where: Prisma.UserDirectoryEntryWhereInput = {};
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserWhereInput {
const where: Prisma.UserWhereInput = {};
if (filters.username !== undefined) {
where.username = { startsWith: filters.username };
}
+3 -13
View File
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module';
import { AdminAuditController } from './admin-audit.controller';
import { AdminAuthController } from './admin-auth.controller';
import { AdminController } from './admin.controller';
@@ -8,9 +7,6 @@ import { AdminRoleGuard } from './admin-role.guard';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersReader } from './admin-users-reader.service';
import { AuditReader } from './audit-reader.service';
import { AuditStatsReader } from './audit-stats.service';
import { UserScopesController } from './user-scopes.controller';
import { UserScopesService } from './user-scopes.service';
/**
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
@@ -35,14 +31,8 @@ import { UserScopesService } from './user-scopes.service';
* by `AuditModule`, so no extra import is needed for it.
*/
@Module({
imports: [AuthModule, RedisModule],
controllers: [
AdminController,
AdminAuthController,
AdminAuditController,
AdminUsersController,
UserScopesController,
],
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
imports: [AuthModule],
controllers: [AdminController, AdminAuthController, AdminAuditController, AdminUsersController],
providers: [AdminRoleGuard, AuditReader, AdminUsersReader],
})
export class AdminModule {}
@@ -1,57 +0,0 @@
import { Type } from 'class-transformer';
import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Query parameters for `GET /api/admin/audit/stats`. Mirrors the
* filter shape of {@link AdminAuditQueryDto} **minus pagination**:
* the stats endpoint always aggregates the whole filtered set, so
* `limit` / `offset` carry no meaning.
*
* Per the chantier brief, the endpoint respects filters strictly —
* an unfiltered call aggregates across the full audit retention
* (365 days by default per ADR-0013). The 5-minute Redis cache
* absorbs the cost of a heavy unfiltered query if the same shape
* lands twice in quick succession.
*/
/** Subset of `AuditAudience` accepted by the filter — matches the list endpoint. */
const AUDIT_AUDIENCES = ['workforce', 'customer'] as const;
type AuditAudienceFilter = (typeof AUDIT_AUDIENCES)[number];
/** Subset of `AuditOutcome` accepted by the filter. */
const AUDIT_OUTCOMES = ['success', 'failure', 'denied'] as const;
type AuditOutcomeFilter = (typeof AUDIT_OUTCOMES)[number];
export class AdminAuditStatsQueryDto {
@IsOptional()
@IsString()
@MaxLength(128)
eventType?: string;
@IsOptional()
@IsString()
@MaxLength(128)
actorIdHash?: string;
@IsOptional()
@IsEnum(AUDIT_AUDIENCES)
audience?: AuditAudienceFilter;
@IsOptional()
@IsEnum(AUDIT_OUTCOMES)
outcome?: AuditOutcomeFilter;
@IsOptional()
@IsString()
@MaxLength(128)
subjectPrefix?: string;
@IsOptional()
@IsISO8601()
createdAtFrom?: string;
@IsOptional()
@IsISO8601()
@Type(() => String)
createdAtTo?: string;
}
@@ -1,199 +0,0 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import { REDIS_CLIENT } from '../redis/redis.token';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
import { AuditStatsReader } from './audit-stats.service';
interface MockTx {
$executeRawUnsafe: jest.Mock;
$queryRawUnsafe: jest.Mock;
}
interface MockPrisma {
$transaction: jest.Mock;
tx: MockTx;
}
interface MockRedis {
get: jest.Mock;
set: jest.Mock;
}
function buildPrisma(opts?: {
dailyRows?: Array<{ day: Date; count: bigint }>;
outcomeRows?: Array<{ outcome: string; count: bigint }>;
eventTypeRows?: Array<{ day: Date; event_type: string; count: bigint }>;
}): MockPrisma {
const tx: MockTx = {
$executeRawUnsafe: jest.fn().mockResolvedValue(0),
$queryRawUnsafe: jest
.fn()
// Order matches the service: dailyVolume → outcomeBreakdown → eventTypeByDay.
.mockResolvedValueOnce(opts?.dailyRows ?? [])
.mockResolvedValueOnce(opts?.outcomeRows ?? [])
.mockResolvedValueOnce(opts?.eventTypeRows ?? []),
};
return {
tx,
$transaction: jest.fn(async (fn: (tx: MockTx) => Promise<unknown>) => fn(tx)),
};
}
function buildRedis(cached?: string): MockRedis {
return {
get: jest.fn().mockResolvedValue(cached ?? null),
set: jest.fn().mockResolvedValue('OK'),
};
}
async function createSubject(prisma: MockPrisma, redis: MockRedis): Promise<AuditStatsReader> {
const moduleRef = await Test.createTestingModule({
providers: [
AuditStatsReader,
{ provide: PrismaService, useValue: prisma },
{ provide: REDIS_CLIENT, useValue: redis },
],
}).compile();
return moduleRef.get(AuditStatsReader);
}
describe('AuditStatsReader', () => {
it('returns the cached payload when Redis has a hit (no DB call)', async () => {
const cached = JSON.stringify({
dailyVolume: [{ day: '2026-05-13', count: 12 }],
outcomeBreakdown: [{ outcome: 'success', count: 12 }],
eventTypeByDay: [{ day: '2026-05-13', eventType: 'auth.sign_in', count: 12 }],
total: 12,
});
const prisma = buildPrisma();
const redis = buildRedis(cached);
const reader = await createSubject(prisma, redis);
const result = await reader.getStats({});
expect(result.total).toBe(12);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.tx.$queryRawUnsafe).not.toHaveBeenCalled();
expect(redis.set).not.toHaveBeenCalled();
});
it('locks the transaction to audit_reader before running GROUP BY queries', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({});
const setRoleCalls = prisma.tx.$executeRawUnsafe.mock.calls;
expect(setRoleCalls[0]?.[0]).toBe('SET LOCAL ROLE audit_reader');
});
it('issues exactly three GROUP BY queries (daily / outcome / by-event-type)', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({});
const queries = prisma.tx.$queryRawUnsafe.mock.calls;
expect(queries).toHaveLength(3);
expect(queries[0]?.[0]).toMatch(
/date_trunc\('day', created_at\)::date AS day[\s\S]*GROUP BY day/,
);
expect(queries[1]?.[0]).toMatch(/outcome::text AS outcome[\s\S]*GROUP BY outcome/);
expect(queries[2]?.[0]).toMatch(/GROUP BY day, event_type/);
});
it('projects rows into the SPA-facing shape (ISO day, numeric count, computed total)', async () => {
const prisma = buildPrisma({
dailyRows: [
{ day: new Date('2026-05-13T00:00:00.000Z'), count: 8n },
{ day: new Date('2026-05-14T00:00:00.000Z'), count: 12n },
],
outcomeRows: [
{ outcome: 'success', count: 18n },
{ outcome: 'failure', count: 2n },
],
eventTypeRows: [
{ day: new Date('2026-05-13T00:00:00.000Z'), event_type: 'auth.sign_in', count: 8n },
],
});
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
const result = await reader.getStats({});
expect(result.dailyVolume).toEqual([
{ day: '2026-05-13', count: 8 },
{ day: '2026-05-14', count: 12 },
]);
expect(result.outcomeBreakdown).toEqual([
{ outcome: 'success', count: 18 },
{ outcome: 'failure', count: 2 },
]);
expect(result.eventTypeByDay).toEqual([
{ day: '2026-05-13', eventType: 'auth.sign_in', count: 8 },
]);
expect(result.total).toBe(20);
});
it('writes the result to Redis with the 5-minute TTL on cache miss', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({ outcome: 'denied' });
expect(redis.set).toHaveBeenCalledTimes(1);
const [key, payload, mode, ttl] = redis.set.mock.calls[0] as [string, string, string, number];
expect(key).toMatch(/^audit:stats:[0-9a-f]{16}$/);
expect(JSON.parse(payload)).toMatchObject({ total: 0 });
expect(mode).toBe('EX');
expect(ttl).toBe(300);
});
it('uses the same cache key for identical filter shapes regardless of key order', async () => {
const prisma1 = buildPrisma();
const redis1 = buildRedis();
const reader1 = await createSubject(prisma1, redis1);
const prisma2 = buildPrisma();
const redis2 = buildRedis();
const reader2 = await createSubject(prisma2, redis2);
const a: AdminAuditStatsQueryDto = { eventType: 'auth.sign_in', outcome: 'success' };
const b: AdminAuditStatsQueryDto = { outcome: 'success', eventType: 'auth.sign_in' };
await reader1.getStats(a);
await reader2.getStats(b);
const key1 = (redis1.get.mock.calls[0] as [string])[0];
const key2 = (redis2.get.mock.calls[0] as [string])[0];
expect(key1).toBe(key2);
});
it('passes filter values as positional parameters (no string concatenation)', async () => {
const prisma = buildPrisma();
const redis = buildRedis();
const reader = await createSubject(prisma, redis);
await reader.getStats({ eventType: 'auth.sign_in', outcome: 'denied' });
// All three queries should carry the same `params` tail —
// event_type then outcome cast to enum. Verify on the first one.
const [, ...params] = prisma.tx.$queryRawUnsafe.mock.calls[0] as [string, ...unknown[]];
expect(params).toEqual(['auth.sign_in', 'denied']);
});
it('survives a Redis-write failure on cache miss (best-effort cache, response still flows)', async () => {
const prisma = buildPrisma({
dailyRows: [{ day: new Date('2026-05-13T00:00:00.000Z'), count: 5n }],
});
const redis = buildRedis();
redis.set.mockRejectedValueOnce(new Error('redis unavailable'));
const reader = await createSubject(prisma, redis);
const result = await reader.getStats({});
expect(result.total).toBe(5);
});
});
@@ -1,226 +0,0 @@
import { createHash } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { PrismaService } from 'nestjs-prisma';
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
import type { AdminAuditStatsQueryDto } from './audit-stats.dto';
/**
* Aggregated audit-event statistics the shape consumed by the
* portal-admin `/audit` page's "Charts" tab (PR 2 of the stats
* chantier).
*
* Each axis matches one of the three charts:
* - `dailyVolume` bar chart of events per UTC day,
* - `outcomeBreakdown` donut of success/failure/denied counts,
* - `eventTypeByDay` stacked-bar of events per day, by type.
*
* Returned to the SPA as plain JSON; consumed by the shared chart
* components without further transformation.
*/
export interface AdminAuditStats {
readonly dailyVolume: ReadonlyArray<{ day: string; count: number }>;
readonly outcomeBreakdown: ReadonlyArray<{ outcome: string; count: number }>;
readonly eventTypeByDay: ReadonlyArray<{ day: string; eventType: string; count: number }>;
/** Sum of `dailyVolume.count` — drives the donut centre label. */
readonly total: number;
}
/**
* Redis cache TTL for stats queries. Audit rows are append-only so
* past aggregations are stable, but new events are continuously
* inserted a 5-minute TTL is the chosen compromise (per the
* chantier brief): admins see at most 5-minute-stale aggregations
* which is fine for "approximate dashboard" usage; not appropriate
* for "did the last event land yet" debugging (use the list
* endpoint for that).
*/
const STATS_CACHE_TTL_SECONDS = 300;
/** Key prefix in Redis so cache keys are scoped under one root. */
const CACHE_KEY_PREFIX = 'audit:stats:';
/**
* `AuditStatsReader` server-side audit aggregations behind a
* Redis cache.
*
* Contract mirrors `AuditReader` for consistency:
* - Every query runs in a transaction that opens with
* `SET LOCAL ROLE audit_reader`, so SELECT is the only DB
* verb that can succeed even if the BFF's connection were
* otherwise privileged.
* - Parameterised SQL only; filter values flow through positional
* parameters, never concatenated into the SQL string.
*
* Cache key is a SHA-256 of the canonical (sorted-keys) JSON
* representation of the filter object deterministic across
* processes, immune to JSON key ordering.
*/
@Injectable()
export class AuditStatsReader {
constructor(
private readonly prisma: PrismaService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
async getStats(filters: AdminAuditStatsQueryDto): Promise<AdminAuditStats> {
const cacheKey = `${CACHE_KEY_PREFIX}${hashFilters(filters)}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as AdminAuditStats;
}
const stats = await this.computeStats(filters);
// Best-effort cache write. If Redis is briefly unavailable, we
// serve the live result and the next call rebuilds the cache.
// The DB read already happened — don't fail the response on a
// cache-write blip.
try {
await this.redis.set(cacheKey, JSON.stringify(stats), 'EX', STATS_CACHE_TTL_SECONDS);
} catch {
// swallow — see comment above
}
return stats;
}
private async computeStats(filters: AdminAuditStatsQueryDto): Promise<AdminAuditStats> {
const { whereSql, params } = buildWhere(filters);
return this.prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_reader`);
// Three GROUP BY queries, each scoped by the same WHERE clause.
// `date_trunc('day', ...)::date` yields a Postgres date (no
// time, no zone) that serializes to `YYYY-MM-DD` directly —
// matches the SPA's chart bucket convention.
const dailyRows = await tx.$queryRawUnsafe<Array<{ day: Date; count: bigint }>>(
`SELECT date_trunc('day', created_at)::date AS day,
COUNT(*)::bigint AS count
FROM "audit"."events"
${whereSql}
GROUP BY day
ORDER BY day`,
...params,
);
const outcomeRows = await tx.$queryRawUnsafe<Array<{ outcome: string; count: bigint }>>(
`SELECT outcome::text AS outcome,
COUNT(*)::bigint AS count
FROM "audit"."events"
${whereSql}
GROUP BY outcome
ORDER BY outcome`,
...params,
);
const eventTypeRows = await tx.$queryRawUnsafe<
Array<{ day: Date; event_type: string; count: bigint }>
>(
`SELECT date_trunc('day', created_at)::date AS day,
event_type,
COUNT(*)::bigint AS count
FROM "audit"."events"
${whereSql}
GROUP BY day, event_type
ORDER BY day, event_type`,
...params,
);
const dailyVolume = dailyRows.map((r) => ({
day: toIsoDay(r.day),
count: Number(r.count),
}));
const total = dailyVolume.reduce((acc, r) => acc + r.count, 0);
return {
dailyVolume,
outcomeBreakdown: outcomeRows.map((r) => ({
outcome: r.outcome,
count: Number(r.count),
})),
eventTypeByDay: eventTypeRows.map((r) => ({
day: toIsoDay(r.day),
eventType: r.event_type,
count: Number(r.count),
})),
total,
};
});
}
}
/**
* Deterministic hash of the filter object. Sorted keys + JSON
* stringify so two requests with the same filters land on the same
* cache key regardless of ordering or whitespace. Truncated to
* 16 hex chars (64 bits) collision probability negligible at
* realistic admin-side query volumes.
*/
function hashFilters(filters: AdminAuditStatsQueryDto): string {
const sortedKeys = Object.keys(filters).sort();
const canonical = sortedKeys
.map((k) => `${k}=${(filters as Record<string, unknown>)[k] ?? ''}`)
.join('&');
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
}
interface BuiltWhere {
readonly whereSql: string;
readonly params: unknown[];
}
/**
* Same shape as `AuditReader`'s `buildWhere` kept in sync by
* convention. Diverges only in that the stats DTO doesn't carry
* `limit` / `offset`, so those clauses don't appear here.
*/
function buildWhere(filters: AdminAuditStatsQueryDto): BuiltWhere {
const clauses: string[] = [];
const params: unknown[] = [];
if (filters.eventType !== undefined) {
params.push(filters.eventType);
clauses.push(`event_type = $${params.length}`);
}
if (filters.actorIdHash !== undefined) {
params.push(filters.actorIdHash);
clauses.push(`actor_id_hash = $${params.length}`);
}
if (filters.audience !== undefined) {
params.push(filters.audience);
clauses.push(`audience = $${params.length}::"audit"."AuditAudience"`);
}
if (filters.outcome !== undefined) {
params.push(filters.outcome);
clauses.push(`outcome = $${params.length}::"audit"."AuditOutcome"`);
}
if (filters.subjectPrefix !== undefined) {
params.push(`${escapeLikeLiteral(filters.subjectPrefix)}%`);
clauses.push(`subject LIKE $${params.length} ESCAPE '\\'`);
}
if (filters.createdAtFrom !== undefined) {
params.push(new Date(filters.createdAtFrom));
clauses.push(`created_at >= $${params.length}`);
}
if (filters.createdAtTo !== undefined) {
params.push(new Date(filters.createdAtTo));
clauses.push(`created_at < $${params.length}`);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
return { whereSql, params };
}
function escapeLikeLiteral(raw: string): string {
return raw.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}
/**
* `date_trunc('day', ...)::date` round-trips through node-postgres
* as a JS Date at UTC midnight. Slice the ISO string back to
* `YYYY-MM-DD` so the SPA gets a stable bucket key `Date#toJSON`
* would otherwise serialise the full ISO timestamp with the local
* timezone offset, which is not what the chart x-axis wants.
*/
function toIsoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
@@ -1,132 +0,0 @@
import type { Request } from 'express';
import type { AuditWriter } from '../audit/audit.service';
import { UserScopesController } from './user-scopes.controller';
import type { UserScopesService } from './user-scopes.service';
function makeFixture(): {
controller: UserScopesController;
service: { list: jest.Mock; grant: jest.Mock; revoke: jest.Mock };
audit: { adminScopeGranted: jest.Mock; adminScopeRevoked: jest.Mock };
} {
const service = {
list: jest.fn(),
grant: jest.fn(),
revoke: jest.fn(),
};
const audit = {
adminScopeGranted: jest.fn().mockResolvedValue(undefined),
adminScopeRevoked: jest.fn().mockResolvedValue(undefined),
};
const controller = new UserScopesController(
service as unknown as UserScopesService,
audit as unknown as AuditWriter,
);
return { controller, service, audit };
}
function makeReq(actorOid: string | undefined): Request {
return {
session: actorOid === undefined ? {} : { user: { oid: actorOid } },
} as unknown as Request;
}
describe('UserScopesController.list', () => {
it('returns the service payload verbatim — no audit on reads', async () => {
const { controller, service, audit } = makeFixture();
service.list.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scopes: [],
});
const page = await controller.list('target-oid');
expect(service.list).toHaveBeenCalledWith('target-oid');
expect(page.user.oid).toBe('target-oid');
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
});
});
describe('UserScopesController.grant', () => {
it('creates the scope and emits admin.scope_granted blocking audit', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scope: {
id: 'new-scope',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
});
const result = await controller.grant(makeReq('admin-oid'), 'target-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(service.grant).toHaveBeenCalledWith('target-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(audit.adminScopeGranted).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
target: { oid: 'target-oid' },
scopeId: 'new-scope',
kind: 'etablissement',
value: '0330800013',
expiresAt: null,
});
expect(result.id).toBe('new-scope');
});
it('propagates service errors without writing the audit row', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockRejectedValue(new Error('boom'));
await expect(
controller.grant(makeReq('admin-oid'), 'target-oid', { kind: 'self' }),
).rejects.toThrow('boom');
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
});
it('skips audit when the session has no actor (defensive — guard guarantees it)', async () => {
const { controller, service, audit } = makeFixture();
service.grant.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
scope: {
id: 'new-scope',
kind: 'self',
value: '',
source: 'admin-ui',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
});
await controller.grant(makeReq(undefined), 'target-oid', { kind: 'self' });
expect(audit.adminScopeGranted).not.toHaveBeenCalled();
});
});
describe('UserScopesController.revoke', () => {
it('deletes and emits admin.scope_revoked', async () => {
const { controller, service, audit } = makeFixture();
service.revoke.mockResolvedValue({
user: { oid: 'target-oid', userId: 'u', displayName: 'Jane', email: 'j@e' },
revoked: { kind: 'delegation', value: '33' },
});
await controller.revoke(makeReq('admin-oid'), 'target-oid', 'scope-1');
expect(service.revoke).toHaveBeenCalledWith('target-oid', 'scope-1');
expect(audit.adminScopeRevoked).toHaveBeenCalledWith({
actor: { oid: 'admin-oid' },
target: { oid: 'target-oid' },
scopeId: 'scope-1',
kind: 'delegation',
value: '33',
});
});
it('propagates service NotFound without writing the audit row', async () => {
const { controller, service, audit } = makeFixture();
service.revoke.mockRejectedValue(new Error('not found'));
await expect(controller.revoke(makeReq('admin-oid'), 'target-oid', 'ghost')).rejects.toThrow();
expect(audit.adminScopeRevoked).not.toHaveBeenCalled();
});
});
@@ -1,93 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { RequireAdmin } from './require-admin.decorator';
import { GrantUserScopeDto, type UserScopeDto, type UserScopesPageDto } from './user-scopes.dto';
import { UserScopesService } from './user-scopes.service';
/**
* `/api/admin/users/:oid/scopes` per ADR-0026 PR 2b admin
* scope-management screen for an existing portal User. Each write
* emits a typed audit row (blocking per ADR-0013); reads are NOT
* audited (low signal, high noise).
*
* The `:oid` is the affected user's Entra `oid` same identifier
* the v1 user-directory list (ADR-0020) uses, so the SPA can link
* directly from the existing `/admin/users` rows.
*/
@ApiTags('admin (user scopes)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/users/:oid/scopes')
@RequireAdmin()
export class UserScopesController {
constructor(
private readonly userScopes: UserScopesService,
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: "List a user's scopes (no audit — read).",
})
@Get()
async list(@Param('oid') oid: string): Promise<UserScopesPageDto> {
return this.userScopes.list(oid);
}
@ApiOperation({
summary: 'Grant a scope to a user — emits `admin.scope_granted` (blocking).',
})
@Post()
async grant(
@Req() req: Request,
@Param('oid') oid: string,
@Body() body: GrantUserScopeDto,
): Promise<UserScopeDto> {
const { user, scope } = await this.userScopes.grant(oid, body);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminScopeGranted({
actor: { oid: actorOid },
target: { oid: user.oid },
scopeId: scope.id,
kind: scope.kind,
value: scope.value,
expiresAt: scope.expiresAt,
});
}
return scope;
}
@ApiOperation({
summary: 'Revoke a scope — emits `admin.scope_revoked` (blocking).',
})
@Delete(':scopeId')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(
@Req() req: Request,
@Param('oid') oid: string,
@Param('scopeId') scopeId: string,
): Promise<void> {
const { user, revoked } = await this.userScopes.revoke(oid, scopeId);
const actorOid = req.session.user?.oid;
if (actorOid !== undefined) {
await this.audit.adminScopeRevoked({
actor: { oid: actorOid },
target: { oid: user.oid },
scopeId,
kind: revoked.kind,
value: revoked.value,
});
}
}
}
@@ -1,68 +0,0 @@
import { IsIn, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
import { SCOPE_KINDS, type ScopeKind } from 'shared-auth';
/**
* Payload accepted by `POST /api/admin/users/:oid/scopes`. Kind is
* validated against the closed catalogue; value validation against
* `Structure.code` / `Delegation.code` / `Region.code` (ADR-0027 PR 1)
* happens server-side in `UserScopesService.grant`. v1 has no
* partial-update; a re-grant with a different `expiresAt` requires
* delete + add.
*/
export class GrantUserScopeDto {
@IsString()
@IsIn(SCOPE_KINDS as ReadonlyArray<string>)
kind!: ScopeKind;
/**
* Value tied to `kind`. Required and non-empty for value-bearing
* kinds (`etablissement` / `delegation` / `region`); must be empty
* (or omitted coerced to '' in the service) for valueless kinds
* (`self` / `siege` / `unrestricted`). The shape match is handled
* by `UserScopesService.grant` so the error surface is one place.
*/
@IsString()
@MaxLength(64)
@IsOptional()
value?: string;
/**
* Optional ISO-8601 expiry. When set and past, `PrismaScopeResolver`
* filters the row out at sign-in (`expiresAt IS NULL OR > NOW()`)
* per ADR-0026 PR 2a supports interim-director-for-two-months
* patterns without a row deletion.
*/
@IsISO8601()
@IsOptional()
expiresAt?: string;
}
/**
* Single row in `GET /api/admin/users/:oid/scopes`. Mirrors
* `UserScope` columns; timestamps as ISO strings so the SPA never
* has to round-trip `Date` instances.
*/
export interface UserScopeDto {
readonly id: string;
readonly kind: string;
readonly value: string;
readonly source: string;
readonly createdAt: string;
readonly expiresAt: string | null;
}
/**
* Response of `GET /api/admin/users/:oid/scopes`. Includes a
* `user` envelope so the SPA can confirm the right target user
* without a second round-trip; `displayName` + `email` are pulled
* from the linked `Person` row.
*/
export interface UserScopesPageDto {
readonly user: {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
};
readonly scopes: ReadonlyArray<UserScopeDto>;
}
@@ -1,311 +0,0 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma';
import { UserScopesService } from './user-scopes.service';
interface PrismaStub {
user: { findUnique: jest.Mock };
userScope: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
structure: { findUnique: jest.Mock };
delegation: { findUnique: jest.Mock };
region: { findUnique: jest.Mock };
}
function makeLogger() {
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
log: jest.Mock;
warn: jest.Mock;
error: jest.Mock;
};
}
function makePrisma(overrides?: Partial<PrismaStub>): PrismaStub {
return {
user: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.user },
userScope: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
...overrides?.userScope,
},
structure: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.structure },
delegation: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.delegation },
region: { findUnique: jest.fn().mockResolvedValue(null), ...overrides?.region },
};
}
function makeSubject(overrides?: Partial<PrismaStub>): {
service: UserScopesService;
prisma: PrismaStub;
logger: ReturnType<typeof makeLogger>;
} {
const prisma = makePrisma(overrides);
const logger = makeLogger();
const service = new UserScopesService(prisma as unknown as PrismaService, logger);
return { service, prisma, logger };
}
const FOUND_USER = {
id: 'user-uuid-1',
person: { firstName: 'Jane', lastName: 'Doe', email: 'jane@apf.example' },
};
describe('UserScopesService.resolveUserByOid', () => {
it('returns the User + composed displayName when found', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
const result = await service.resolveUserByOid('entra-oid');
expect(result).toEqual({
oid: 'entra-oid',
userId: 'user-uuid-1',
displayName: 'Jane Doe',
email: 'jane@apf.example',
});
});
it('throws NotFoundException when the User does not exist', async () => {
const { service } = makeSubject();
await expect(service.resolveUserByOid('ghost-oid')).rejects.toBeInstanceOf(NotFoundException);
});
it('falls back to "(unnamed)" when both firstName and lastName are empty', async () => {
const { service } = makeSubject({
user: {
findUnique: jest.fn().mockResolvedValue({
id: 'u',
person: { firstName: '', lastName: '', email: null },
}),
},
});
const result = await service.resolveUserByOid('o');
expect(result.displayName).toBe('(unnamed)');
expect(result.email).toBeNull();
});
});
describe('UserScopesService.list', () => {
it('returns scopes ordered by kind then value with ISO timestamps', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn().mockResolvedValue([
{
id: 's1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: new Date('2026-05-26T10:00:00.000Z'),
expiresAt: null,
},
]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
});
const result = await service.list('entra-oid');
expect(prisma.userScope.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-uuid-1' },
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
}),
);
expect(result.scopes).toEqual([
{
id: 's1',
kind: 'etablissement',
value: '0330800013',
source: 'seed',
createdAt: '2026-05-26T10:00:00.000Z',
expiresAt: null,
},
]);
});
});
describe('UserScopesService.grant — value-bearing kinds', () => {
it('creates the row when the etablissement value matches an existing Structure', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 'new-scope',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
createdAt: new Date('2026-05-26T10:00:00.000Z'),
expiresAt: null,
}),
},
});
const out = await service.grant('entra-oid', {
kind: 'etablissement',
value: '0330800013',
});
expect(prisma.structure.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { code: '0330800013' } }),
);
expect(prisma.userScope.create).toHaveBeenCalledWith({
data: {
userId: 'user-uuid-1',
kind: 'etablissement',
value: '0330800013',
source: 'admin-ui',
expiresAt: null,
},
select: expect.any(Object),
});
expect(out.scope.kind).toBe('etablissement');
expect(out.scope.value).toBe('0330800013');
expect(out.scope.source).toBe('admin-ui');
});
it('rejects when the value does not match any Structure row', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue(null) },
});
await expect(
service.grant('entra-oid', { kind: 'etablissement', value: 'bogus' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects when the value is empty for a value-bearing kind', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
await expect(
service.grant('entra-oid', { kind: 'delegation', value: '' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('validates delegation values against the Delegation table', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
delegation: { findUnique: jest.fn().mockResolvedValue({ code: '33' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 's',
kind: 'delegation',
value: '33',
source: 'admin-ui',
createdAt: new Date(),
expiresAt: null,
}),
},
});
await service.grant('entra-oid', { kind: 'delegation', value: '33' });
expect(prisma.delegation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { code: '33' } }),
);
});
});
describe('UserScopesService.grant — valueless kinds', () => {
it('writes value="" for unrestricted and rejects non-empty values', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 's',
kind: 'unrestricted',
value: '',
source: 'admin-ui',
createdAt: new Date(),
expiresAt: null,
}),
},
});
await service.grant('entra-oid', { kind: 'unrestricted' });
expect(prisma.userScope.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ value: '' }),
}),
);
await expect(
service.grant('entra-oid', { kind: 'unrestricted', value: 'something' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('UserScopesService.grant — duplicate detection', () => {
it('translates Prisma P2002 into a 400 with a friendly message', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
structure: { findUnique: jest.fn().mockResolvedValue({ code: '0330800013' }) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
create: jest.fn().mockRejectedValue(Object.assign(new Error('unique'), { code: 'P2002' })),
},
});
await expect(
service.grant('entra-oid', { kind: 'etablissement', value: '0330800013' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('UserScopesService.revoke', () => {
it('deletes the row and returns the resolved tuple for audit', async () => {
const { service, prisma } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
id: 's1',
userId: 'user-uuid-1',
kind: 'delegation',
value: '33',
}),
create: jest.fn(),
delete: jest.fn().mockResolvedValue(undefined),
},
});
const out = await service.revoke('entra-oid', 's1');
expect(prisma.userScope.delete).toHaveBeenCalledWith({ where: { id: 's1' } });
expect(out.revoked).toEqual({ kind: 'delegation', value: '33' });
});
it('throws NotFoundException when the scope belongs to a different user', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
userScope: {
findMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
id: 's1',
userId: 'other-user',
kind: 'delegation',
value: '33',
}),
create: jest.fn(),
delete: jest.fn(),
},
});
await expect(service.revoke('entra-oid', 's1')).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFoundException when no scope exists for that id', async () => {
const { service } = makeSubject({
user: { findUnique: jest.fn().mockResolvedValue(FOUND_USER) },
});
await expect(service.revoke('entra-oid', 'ghost')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -1,266 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isScopeKind, type ScopeKind } from 'shared-auth';
import type { UserScopeDto, UserScopesPageDto } from './user-scopes.dto';
/**
* Kinds that carry a value referencing an ADR-0027 row. The service
* resolves each to its matching table at `grant` time.
*/
const VALUE_BEARING_KINDS = new Set<ScopeKind>(['etablissement', 'delegation', 'region']);
export interface ResolvedUser {
readonly oid: string;
readonly userId: string;
readonly displayName: string;
readonly email: string | null;
}
/**
* `UserScopesService` owns the CRUD surface behind the ADR-0026
* PR 2b admin scope-management screen.
*
* Read side reads `user_scopes` joined to `User` + `Person` (so the
* SPA gets the affected user's display info in the same round-trip).
* Write side validates the scope tuple before insert:
*
* - `kind` is enforced by the DTO's `IsIn(SCOPE_KINDS)` plus the
* type-guard `isScopeKind` defensively.
* - For value-bearing kinds (`etablissement` / `delegation` /
* `region`), `value` must match an existing row in the
* corresponding ADR-0027 table. The lookup is the only DB call
* beyond the insert itself.
* - For valueless kinds (`self` / `siege` / `unrestricted`), the
* service coerces `value` to `''` and rejects any non-empty
* attempt.
*
* `UserScope.source` is hardcoded to `'admin-ui'` per the
* PERSON_SOURCES catalogue see ADR-0026 PR 1's `person-source.ts`
* for the shared catalogue rules. (`UserScope.source` is not in that
* catalogue at the drift-gate level today; the seed writes 'seed'
* and we write 'admin-ui'. ADR-0029 will add upstream-sync values
* and likely formalise the UserScope.source catalogue then.)
*/
@Injectable()
export class UserScopesService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: Logger,
) {}
/**
* Resolves an Entra `oid` to the new ADR-0026 `User` row + its
* linked `Person`. Throws 404 when no User row exists for the oid
* caller may need to surface "this persona has never signed in
* and is not in the seed" with a hint.
*/
async resolveUserByOid(oid: string): Promise<ResolvedUser> {
const user = await this.prisma.user.findUnique({
where: { entraOid: oid },
select: {
id: true,
person: { select: { firstName: true, lastName: true, email: true } },
},
});
if (user === null) {
throw new NotFoundException(`No portal User found for Entra oid ${oid}.`);
}
return {
oid,
userId: user.id,
displayName: composeDisplay(user.person.firstName, user.person.lastName),
email: user.person.email,
};
}
async list(oid: string): Promise<UserScopesPageDto> {
const target = await this.resolveUserByOid(oid);
const rows = await this.prisma.userScope.findMany({
where: { userId: target.userId },
orderBy: [{ kind: 'asc' }, { value: 'asc' }],
select: {
id: true,
kind: true,
value: true,
source: true,
createdAt: true,
expiresAt: true,
},
});
return {
user: target,
scopes: rows.map(toDto),
};
}
/**
* Grant a scope. Validates the tuple, then inserts. Returns the
* resolved User (so the controller can include it in the audit
* payload) plus the freshly-created `UserScope` row.
*/
async grant(
oid: string,
input: { kind: string; value?: string; expiresAt?: string },
): Promise<{ user: ResolvedUser; scope: UserScopeDto }> {
const target = await this.resolveUserByOid(oid);
if (!isScopeKind(input.kind)) {
// The DTO already enforces this — defensive belt-and-braces.
throw new BadRequestException(`Unknown scope kind: ${input.kind}.`);
}
const normalisedValue = await this.validateValueForKind(input.kind, input.value ?? '');
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
try {
const row = await this.prisma.userScope.create({
data: {
userId: target.userId,
kind: input.kind,
value: normalisedValue,
source: 'admin-ui',
expiresAt,
},
select: {
id: true,
kind: true,
value: true,
source: true,
createdAt: true,
expiresAt: true,
},
});
return { user: target, scope: toDto(row) };
} catch (err) {
// Prisma's unique-constraint on (userId, kind, value) raises
// P2002. Surface it as 409-ish via a BadRequest so the admin UI
// can show "already granted" without a stack trace.
if (isPrismaP2002(err)) {
throw new BadRequestException(
`Scope already granted to this user: kind=${input.kind}, value=${normalisedValue || '(empty)'}.`,
);
}
throw err;
}
}
/**
* Revoke a scope by its row id. Returns the resolved user + the
* deleted tuple (kind / value) so the audit row reflects what the
* row WAS the row itself is gone by the time the controller
* audits.
*/
async revoke(
oid: string,
scopeId: string,
): Promise<{ user: ResolvedUser; revoked: { kind: string; value: string } }> {
const target = await this.resolveUserByOid(oid);
const existing = await this.prisma.userScope.findUnique({
where: { id: scopeId },
select: { id: true, userId: true, kind: true, value: true },
});
if (existing === null || existing.userId !== target.userId) {
// Don't leak "this scope id exists but is for someone else" —
// 404 either way.
throw new NotFoundException(`Scope ${scopeId} not found for user ${oid}.`);
}
await this.prisma.userScope.delete({ where: { id: scopeId } });
this.logger.log(
{
event: 'user_scopes.revoked',
oid,
scopeId,
kind: existing.kind,
value: existing.value,
},
'UserScopesService',
);
return {
user: target,
revoked: { kind: existing.kind, value: existing.value },
};
}
private async validateValueForKind(kind: ScopeKind, rawValue: string): Promise<string> {
if (!VALUE_BEARING_KINDS.has(kind)) {
if (rawValue !== '') {
throw new BadRequestException(`Scope kind '${kind}' is valueless — value must be empty.`);
}
return '';
}
if (rawValue === '') {
throw new BadRequestException(`Scope kind '${kind}' requires a non-empty value.`);
}
// Look up the referenced row. Single existence check per kind;
// ADR-0026 PR 1's "no FK on UserScope.value" rationale lets the
// value persist even after the target is decommissioned, so we
// validate only at the write path.
//
// Initialise `exists = false` so TypeScript sees it as definitely
// assigned regardless of how the switch lands (the type-system
// can't see that `VALUE_BEARING_KINDS.has` already narrowed
// `kind` to the three branches). If somehow a non-value-bearing
// kind reached this code, the false default makes the next `if`
// throw with the same friendly "does not match" message — safe
// by construction.
let exists = false;
switch (kind) {
case 'etablissement':
exists =
(await this.prisma.structure.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
case 'delegation':
exists =
(await this.prisma.delegation.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
case 'region':
exists =
(await this.prisma.region.findUnique({
where: { code: rawValue },
select: { code: true },
})) !== null;
break;
}
if (!exists) {
throw new BadRequestException(`Scope value '${rawValue}' does not match any ${kind} row.`);
}
return rawValue;
}
}
function toDto(row: {
id: string;
kind: string;
value: string;
source: string;
createdAt: Date;
expiresAt: Date | null;
}): UserScopeDto {
return {
id: row.id,
kind: row.kind,
value: row.value,
source: row.source,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt === null ? null : row.expiresAt.toISOString(),
};
}
function composeDisplay(firstName: string, lastName: string): string {
const combined = `${firstName} ${lastName}`.trim();
return combined === '' ? '(unnamed)' : combined;
}
function isPrismaP2002(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: unknown }).code === 'P2002'
);
}
-2
View File
@@ -8,7 +8,6 @@ import { AdminModule } from '../admin/admin.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { DownstreamModule } from '../downstream/downstream.module';
import { AiBridgeModule } from '../grpc/ai-bridge/ai-bridge.module';
import { MeModule } from '../me/me.module';
import { RedisModule } from '../redis/redis.module';
import { SecurityModule } from '../security/security.module';
@@ -26,7 +25,6 @@ import { UsersModule } from '../users/users.module';
SecurityModule,
HealthModule,
AdminModule,
AiBridgeModule,
DownstreamModule,
MeModule,
UsersModule,
@@ -469,67 +469,4 @@ describe('AuditWriter — typed event methods', () => {
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
});
});
describe('authorizationDenied()', () => {
it('records auth.authorization_denied with kind=privilege payload', async () => {
const { writer, prisma, hashUserId } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/audit',
kind: 'privilege',
required: ['Portal.Auditor'],
held: ['Portal.Admin'],
});
expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
const row = extractInsertedRow(prisma);
expect(row.eventType).toBe('auth.authorization_denied');
expect(row.outcome).toBe('denied');
expect(row.subject).toBe('GET /api/audit');
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'privilege',
required: ['Portal.Auditor'],
held: ['Portal.Admin'],
}),
);
});
it('records kind=role with the role lists', async () => {
const { writer, prisma } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/hr/payroll',
kind: 'role',
required: ['rh', 'responsable-paie'],
held: ['collaborateur'],
});
const row = extractInsertedRow(prisma);
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'role',
required: ['rh', 'responsable-paie'],
held: ['collaborateur'],
}),
);
});
it('records kind=scope with the scope descriptors', async () => {
const { writer, prisma } = await createSubject();
await writer.authorizationDenied({
actor: { oid: 'user-oid' },
attemptedRoute: 'GET /api/etablissement/0330800021',
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
});
const row = extractInsertedRow(prisma);
expect(row.payloadJson).toBe(
JSON.stringify({
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
}),
);
});
});
});
-100
View File
@@ -5,12 +5,8 @@ import { PrismaService } from 'nestjs-prisma';
import type {
AdminAccessDeniedInput,
AdminAuditQueryInput,
AdminAuditStatsQueryInput,
AdminScopeGrantedInput,
AdminScopeRevokedInput,
AdminUsersQueryInput,
AuditEventInput,
AuthorizationDeniedInput,
MfaRequiredInput,
SignInActor,
SignInFailedInput,
@@ -178,30 +174,6 @@ export class AuditWriter {
});
}
/**
* Typed event: an admin queried the audit-log STATISTICS endpoint
* (`GET /api/admin/audit/stats`). Same deterrent posture as
* {@link adminAuditQuery} but separates the event type so an
* auditor can spot "the admin scanned aggregations" vs "the admin
* paged through rows" different observation signals.
*
* Payload carries `total` (the size of the aggregated dataset)
* rather than `resultCount` stats responses don't paginate, and
* `total` is what tells a reviewer how wide the scan was.
*/
async adminAuditStatsQuery(input: AdminAuditStatsQueryInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.audit.stats.query',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
payload: {
filters: input.filters,
total: input.total,
},
});
}
/**
* Typed event: an admin queried the user-directory viewer. Same
* deterrent posture as {@link adminAuditQuery} per ADR-0020
@@ -222,50 +194,6 @@ export class AuditWriter {
});
}
/**
* Typed event: an admin granted a scope to a user via the ADR-0026
* PR 2b scope-management screen. Blocking per ADR-0013 a failed
* audit write means the scope write itself is rolled back upstream.
* The `subject` is `user:<oid>` of the affected user, so an auditor
* pivots on the *target* of the change, not the actor.
*/
async adminScopeGranted(input: AdminScopeGrantedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_granted',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
expiresAt: input.expiresAt,
},
});
}
/**
* Typed event: an admin revoked a scope from a user. Payload
* carries the resolved tuple at the moment of revocation so the
* deletion is reconstructable from the audit log alone the
* `user_scopes` row itself is gone by then.
*/
async adminScopeRevoked(input: AdminScopeRevokedInput): Promise<void> {
await this.recordEvent({
eventType: 'admin.scope_revoked',
audience: 'workforce',
outcome: 'success',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: `user:${input.target.oid}`,
payload: {
scopeId: input.scopeId,
kind: input.kind,
value: input.value,
},
});
}
/**
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
* because the session's `roles` claim does not include `Portal.Admin`.
@@ -286,34 +214,6 @@ export class AuditWriter {
});
}
/**
* Typed event: a request was rejected by one of the three new
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
* an auditor can keep the existing admin-surface signal clean
* while tracking the broader role/scope denial surface as it
* grows.
*
* `outcome=denied` matches the existing posture: the user *is*
* authenticated, the action is just refused. The `required` and
* `held` payload arrays let an auditor pivot on "tried admin
* while logged in as RH" without joining anything.
*/
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
await this.recordEvent({
eventType: 'auth.authorization_denied',
audience: 'workforce',
outcome: 'denied',
actorIdHash: this.hashUserId.hash(input.actor.oid),
subject: input.attemptedRoute,
payload: {
kind: input.kind,
required: input.required,
held: input.held,
},
});
}
async recordEvent(input: AuditEventInput): Promise<void> {
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
const actorIdHash =
-82
View File
@@ -133,88 +133,6 @@ export interface AdminUsersQueryInput {
resultCount: number;
}
/**
* Admin granted a scope to a user via the ADR-0026 PR 2b admin
* screen. The `target` field carries the affected user's Entra `oid`
* (the URL-path identifier of the scope-management screen). Stored
* as the audit row's `subject` (`user:<oid>`). Payload captures the
* scope tuple + the resulting row id + the expiry so a reviewer can
* spot operator drift without re-querying.
*/
export interface AdminScopeGrantedInput {
actor: { oid: string };
target: { oid: string };
scopeId: string;
kind: string;
value: string;
expiresAt: string | null;
}
/**
* Admin revoked (deleted) a scope row from a user. Same target
* convention as {@link AdminScopeGrantedInput}; payload carries the
* resolved tuple at the moment of revocation so the deletion is
* reconstructable from the audit log even after the row itself is
* gone.
*/
export interface AdminScopeRevokedInput {
actor: { oid: string };
target: { oid: string };
scopeId: string;
kind: string;
value: string;
}
/**
* Audit aggregations read `GET /api/admin/audit/stats`. Same
* deterrent posture as {@link AdminAuditQueryInput} (every admin
* read is auditable per ADR-0020) but the payload captures the
* `total` event count of the aggregated set instead of the
* paginated `resultCount` there's no pagination on stats, the
* value carries more "size of the scan" signal.
*/
export interface AdminAuditStatsQueryInput {
actor: { oid: string };
filters: Record<string, unknown>;
total: number;
}
/**
* Discriminator on `AuthorizationDeniedInput.kind` which of the
* three new ADR-0025 guards rejected the request. Stored in the
* payload so an auditor can spot "what kind of authorization
* failed" without parsing the route. The existing
* `admin.access_denied` event keeps its own type for backwards
* compatibility with the legacy `AdminRoleGuard` audit posture.
*/
export type AuthorizationDeniedKind = 'privilege' | 'role' | 'scope';
export interface AuthorizationDeniedInput {
actor: { oid: string };
/** Canonical "{METHOD} {originalUrl}" of the rejected request. */
attemptedRoute: string;
/** Which guard fired the rejection. */
kind: AuthorizationDeniedKind;
/**
* For `kind: 'privilege' | 'role'`: the catalogue values the
* guard required. For `kind: 'scope'`: the route's resource
* descriptor serialised into a `{kind, value}` map. Stored as
* a generic `string[]` rather than the union of `Privilege` /
* `FunctionalRole` so the audit module stays decoupled from the
* shared catalogue.
*/
required: readonly string[];
/**
* What the principal actually held on the matching axis at the
* moment of denial `privileges[]` for privilege/admin denials,
* `roles[]` for role denials, `scopes[]` (serialised as
* `kind` or `kind:value` strings) for scope denials. Lets an
* auditor spot "tried admin while logged in as RH" patterns
* without joining anything.
*/
held: readonly string[];
}
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
@@ -1,267 +0,0 @@
/**
* Persona-matrix integration tests per ADR-0025 §"More Information"
* phasing step 2: the three new guards exercised against every
* persona provisioned in the `apfrd.onmicrosoft.com` test tenant on
* 2026-05-20. Each test instantiates the guard with the persona's
* resolved `Principal` (the shape `PrincipalBuilder` would have
* produced at sign-in) and asserts the guard either passes or
* denies the matrix proves the contracts hold against the real
* provisioning matrix, not just synthesised toy principals.
*
* Scopes are stubbed `unrestricted` for every persona in v1 per
* ADR-0025 §331 (the per-persona scope values land with the
* Prisma `user_scopes` table in a later PR). The scope guard is
* therefore exercised here against synthesised varied principals,
* not against the persona matrix.
*/
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { FunctionalRole, Principal, Privilege } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequirePrivilegeGuard } from './require-privilege.guard';
import { RequireRoleGuard } from './require-role.guard';
interface Persona {
readonly label: string;
readonly privileges: readonly Privilege[];
readonly roles: readonly FunctionalRole[];
}
// Verbatim copy of the persona matrix from
// `principal-builder.spec.ts`. Kept here as a local constant so a
// reviewer sees the same 19-row table both files assert against;
// the two specs cover different layers (builder, then guards).
const PERSONAS: readonly Persona[] = [
{ label: 'admin', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'] },
{
label: 'directeur-bordeaux',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'directeur-complexe',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{ label: 'rh-aquitaine', privileges: [], roles: ['collaborateur', 'rh', 'formation'] },
{
label: 'rh-siege',
privileges: [],
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
},
{ label: 'collaborateur-simple', privileges: [], roles: ['collaborateur'] },
{ label: 'tresorier-bordeaux', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'] },
{
label: 'dpo',
privileges: ['Portal.DPO', 'Portal.Auditor'],
roles: ['collaborateur', 'dpo', 'qualite'],
},
{ label: 'it', privileges: [], roles: ['collaborateur', 'it'] },
{
label: 'benevole-aquitaine',
privileges: [],
roles: ['delegue', 'benevole', 'benevole-responsable'],
},
{ label: 'chef-equipe-bordeaux', privileges: [], roles: ['collaborateur', 'chef-equipe'] },
{ label: 'chef-service-bordeaux', privileges: [], roles: ['collaborateur', 'chef-service'] },
{
label: 'directeur-territorial-aquitaine',
privileges: [],
roles: ['collaborateur', 'directeur-territorial'],
},
{ label: 'juriste-siege', privileges: [], roles: ['collaborateur', 'juriste'] },
{ label: 'rssi', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'] },
{ label: 'communication-siege', privileges: [], roles: ['collaborateur', 'communication'] },
{ label: 'elu-ca-national', privileges: [], roles: ['elu-ca'] },
{ label: 'president-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-president'] },
{ label: 'secretaire-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'] },
];
function principalOf(persona: Persona): Principal {
return {
user: {
id: `oid-${persona.label}`,
personId: `oid-${persona.label}`,
entraOid: `oid-${persona.label}`,
tenantId: 'tenant-1',
displayName: persona.label,
},
privileges: persona.privileges,
roles: persona.roles,
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeCtxFor(principal: Principal): ExecutionContext {
const req = {
session: { principal },
method: 'GET',
originalUrl: '/api/test',
} as unknown as Request;
return {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'TestController',
} as unknown as ExecutionContext;
}
function privilegeGuardFor(metadata: readonly Privilege[]) {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(metadata),
} as unknown as Reflector;
const audit = jest.fn().mockResolvedValue(undefined);
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
}
function roleGuardFor(metadata: readonly FunctionalRole[]) {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(metadata),
} as unknown as Reflector;
const audit = jest.fn().mockResolvedValue(undefined);
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireRoleGuard(reflector, writer), audit };
}
/**
* Each row asserts which personas should pass a given guard. The
* complement (everyone NOT in `allowed`) must be denied the
* `expect denied` block at the bottom of each describe catches a
* regression that would silently let extra personas through.
*/
interface Matrix {
readonly title: string;
readonly allowed: ReadonlyArray<string>;
}
const PRIVILEGE_MATRIX: ReadonlyArray<{
metadata: readonly Privilege[];
matrix: Matrix;
}> = [
{
metadata: ['Portal.Admin'],
matrix: { title: '@RequirePrivilege(Portal.Admin)', allowed: ['admin'] },
},
{
metadata: ['Portal.DPO'],
matrix: { title: '@RequirePrivilege(Portal.DPO)', allowed: ['dpo'] },
},
{
metadata: ['Portal.Auditor'],
matrix: { title: '@RequirePrivilege(Portal.Auditor)', allowed: ['dpo'] },
},
{
metadata: ['Portal.SecurityOfficer'],
matrix: { title: '@RequirePrivilege(Portal.SecurityOfficer)', allowed: ['rssi'] },
},
{
metadata: ['Portal.Admin', 'Portal.DPO'],
matrix: {
title: '@RequirePrivilege(Portal.Admin, Portal.DPO) — OR composition',
allowed: ['admin', 'dpo'],
},
},
];
const ROLE_MATRIX: ReadonlyArray<{
metadata: readonly FunctionalRole[];
matrix: Matrix;
}> = [
{
metadata: ['rh'],
matrix: { title: '@RequireRole(rh)', allowed: ['admin', 'rh-aquitaine', 'rh-siege'] },
},
{
metadata: ['directeur-etablissement'],
matrix: {
title: '@RequireRole(directeur-etablissement)',
allowed: ['directeur-bordeaux', 'directeur-complexe'],
},
},
{
metadata: ['rh', 'comptable', 'responsable-paie'],
matrix: {
title: '@RequireRole(rh, comptable, responsable-paie) — OR composition',
allowed: ['admin', 'rh-aquitaine', 'rh-siege'],
},
},
{
metadata: ['elu-cd'],
matrix: {
title: '@RequireRole(elu-cd) — every CD member',
allowed: ['tresorier-bordeaux', 'president-cd-aquitaine', 'secretaire-cd-aquitaine'],
},
},
{
metadata: ['benevole-responsable'],
matrix: {
title: '@RequireRole(benevole-responsable)',
allowed: ['benevole-aquitaine'],
},
},
{
metadata: ['collaborateur'],
matrix: {
title: '@RequireRole(collaborateur) — every workforce persona',
allowed: [
'admin',
'directeur-bordeaux',
'directeur-complexe',
'rh-aquitaine',
'rh-siege',
'collaborateur-simple',
'dpo',
'it',
'chef-equipe-bordeaux',
'chef-service-bordeaux',
'directeur-territorial-aquitaine',
'juriste-siege',
'rssi',
'communication-siege',
],
},
},
];
describe('Privilege guard — persona matrix', () => {
for (const { metadata, matrix } of PRIVILEGE_MATRIX) {
describe(matrix.title, () => {
for (const persona of PERSONAS) {
const shouldPass = matrix.allowed.includes(persona.label);
const verb = shouldPass ? 'allows' : 'denies';
it(`${verb} ${persona.label}`, async () => {
const { guard } = privilegeGuardFor(metadata);
const ctx = makeCtxFor(principalOf(persona));
if (shouldPass) {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
} else {
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
}
});
}
});
}
});
describe('Role guard — persona matrix', () => {
for (const { metadata, matrix } of ROLE_MATRIX) {
describe(matrix.title, () => {
for (const persona of PERSONAS) {
const shouldPass = matrix.allowed.includes(persona.label);
const verb = shouldPass ? 'allows' : 'denies';
it(`${verb} ${persona.label}`, async () => {
const { guard } = roleGuardFor(metadata);
const ctx = makeCtxFor(principalOf(persona));
if (shouldPass) {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
} else {
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
}
});
}
});
}
});
@@ -147,44 +147,14 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller
// The directory service is a best-effort dependency — a noop mock
// is sufficient for the controller's behavioural assertions.
const userDirectory = { recordSignIn: jest.fn().mockResolvedValue(undefined) };
// The provisioner is blocking per ADR-0026 — the controller specs
// don't exercise the provisioning behaviour itself, so a noop mock
// returning a fixed UUID pair is enough to let SessionEstablisher
// proceed.
const personUserProvisioner = {
ensureUser: jest.fn().mockResolvedValue({
userId: '00000000-0000-0000-0000-000000000fff',
personId: '00000000-0000-0000-0000-000000000eee',
}),
};
// Real SessionEstablisher with the same mocks the legacy tests
// already wire — keeps the behavioural assertions on session
// fields / audit calls untouched after the controller refactor.
const principalBuilder = {
// The auth.controller specs do not assert on the principal
// shape; the resolved value just has to be a valid `Principal`
// so SessionEstablisher persists it without throwing.
build: jest.fn().mockResolvedValue({
user: {
id: '00000000-0000-0000-0000-000000000fff',
personId: '00000000-0000-0000-0000-000000000eee',
entraOid: 'oid',
tenantId: 'tid',
displayName: 'Jane',
},
privileges: [],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: [],
}),
};
const sessionEstablisher = new SessionEstablisher(
logger as unknown as Logger,
userSessionIndex as unknown as UserSessionIndexService,
audit as unknown as AuditWriter,
userDirectory as unknown as UserDirectoryService,
personUserProvisioner as unknown as import('../users/person-and-user-provisioner.service').PersonAndUserProvisioner,
principalBuilder as unknown as import('./principal-builder').PrincipalBuilder,
);
return {
controller: new AuthController(
+1 -53
View File
@@ -2,20 +2,12 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
import { Module } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { assertEntraConfig } from '../config/check-entra-config';
import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map';
import { SessionModule } from '../session/session.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
import { MSAL_CLIENT } from './msal-client.token';
import { PrincipalBuilder } from './principal-builder';
import { PrismaScopeResolver } from './prisma-scope-resolver';
import { RequireMfaGuard } from './require-mfa.guard';
import { RequirePrivilegeGuard } from './require-privilege.guard';
import { RequireRoleGuard } from './require-role.guard';
import { RequireScopeGuard } from './require-scope.guard';
import { ScopeResolver } from './scope-resolver';
import { SessionEstablisher } from './session-establisher.service';
/**
@@ -54,43 +46,11 @@ import { SessionEstablisher } from './session-establisher.service';
providers: [
AuthService,
RequireMfaGuard,
RequirePrivilegeGuard,
RequireRoleGuard,
RequireScopeGuard,
SessionEstablisher,
PrincipalBuilder,
// PrismaScopeResolver replaces StubScopeResolver per ADR-0026 PR 2.
// The stub class stays exported from scope-resolver.ts as a
// fallback for spec fixtures / future "force-unrestricted" dev
// modes, but it is no longer the default wiring.
{ provide: ScopeResolver, useClass: PrismaScopeResolver },
{
provide: ENTRA_CONFIG,
useFactory: () => assertEntraConfig(),
},
{
provide: ENTRA_GROUP_TO_ROLE_RESOLVER,
inject: [Logger],
useFactory: (logger: Logger) => {
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
onWarn: (event, payload) => logger.warn({ event, ...payload }, 'AuthModule'),
});
// Log the post-load summary unconditionally so an operator
// grepping the boot log can confirm how many functional
// roles are wired without inspecting the JSON file. The
// file path (if any) helps diagnose env-var / cwd mismatches.
logger.log(
{
event: 'auth.entra_group_map_loaded',
sourcePath,
mappingCount: resolver.size,
coveredRoles: resolver.coveredRoles(),
},
'AuthModule',
);
return resolver;
},
},
{
provide: MSAL_CLIENT,
inject: [ENTRA_CONFIG, Logger],
@@ -132,18 +92,6 @@ import { SessionEstablisher } from './session-establisher.service';
}),
},
],
exports: [
ENTRA_CONFIG,
ENTRA_GROUP_TO_ROLE_RESOLVER,
MSAL_CLIENT,
RequireMfaGuard,
RequirePrivilegeGuard,
RequireRoleGuard,
RequireScopeGuard,
AuthService,
PrincipalBuilder,
ScopeResolver,
SessionEstablisher,
],
exports: [ENTRA_CONFIG, MSAL_CLIENT, RequireMfaGuard, AuthService, SessionEstablisher],
})
export class AuthModule {}
@@ -38,7 +38,6 @@ function makeAuthResult(
claims: Partial<{
amr: string[];
roles: unknown;
groups: unknown;
oid: string;
tid: string;
name: string;
@@ -59,12 +58,6 @@ function makeAuthResult(
if (Object.prototype.hasOwnProperty.call(claims, 'roles')) {
idTokenClaims['roles'] = claims.roles;
}
// Same opt-in pattern for `groups` — Entra emits it only when
// the app registration sets `groupMembershipClaims: 'SecurityGroup'`
// and the user is in at least one group.
if (Object.prototype.hasOwnProperty.call(claims, 'groups')) {
idTokenClaims['groups'] = claims.groups;
}
return {
idTokenClaims,
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
@@ -131,7 +124,6 @@ describe('AuthService.completeAuthCodeFlow', () => {
displayName: 'Jane Doe',
amr: ['pwd', 'mfa'],
roles: [],
groups: [],
});
});
@@ -228,80 +220,6 @@ describe('AuthService.completeAuthCodeFlow', () => {
expect(user.roles).toEqual(['admin', 'editor']);
});
// `groups` claim — extracted identically to `roles`. The raw
// GUIDs are surfaced here; resolution to `apf-role-*` slugs
// happens downstream in `PrincipalBuilder` per ADR-0025.
it('surfaces the `groups` claim when Entra includes it (security-group member)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(
makeAuthResult({
groups: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'],
}),
);
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
]);
});
it('returns an empty `groups` array when the claim is absent (no group membership)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({}));
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([]);
});
it('returns an empty `groups` array when the claim is non-array (defensive)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({ groups: 'oops' }));
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([]);
});
it('drops non-string entries from `groups` (defensive)', async () => {
const acquireTokenByCode = jest.fn().mockResolvedValue(
makeAuthResult({
groups: [
'11111111-1111-1111-1111-111111111111',
42,
null,
'22222222-2222-2222-2222-222222222222',
],
}),
);
const { service } = makeService({ acquireTokenByCode });
const user = await service.completeAuthCodeFlow(
'code',
PRE_AUTH_OK.state,
PRE_AUTH_OK,
ENTRA.redirectUri,
PRE_AUTH_OK.createdAt,
);
expect(user.groups).toEqual([
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
]);
});
it('throws token-exchange-failed when MSAL throws', async () => {
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
const { service } = makeService({ acquireTokenByCode });
+5 -31
View File
@@ -38,23 +38,11 @@ export interface AuthCodeFlowStart {
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
* freshness checks once the session lands.
* - `roles`: Entra app roles assigned to this user on the BFF's
* app registration. These are the four `Portal.*` *privileges*
* per ADR-0025 (`Portal.Admin`, `Portal.Auditor`,
* `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the
* existing `@RequireAdmin()` guard and the upcoming
* `@RequirePrivilege()` decorator. Always present in the shape
* an empty array means the user has no app role on this app
* registration, not that the claim was unparseable.
* - `groups`: Entra security-group GUIDs the user belongs to.
* Emitted by the `groups` claim once the app registration sets
* `groupMembershipClaims: 'SecurityGroup'` (per ADR-0025
* §"Sources of truth — Entra-side configuration"). The
* `PrincipalBuilder` resolves these GUIDs to `apf-role-*` slugs
* via the `EntraGroupToRoleResolver`. Always present empty
* means either the user has no group membership or the claim
* was not configured on the app registration. Unknown GUIDs
* are dropped at resolve time, not at extraction time, so an
* audit reader still sees the raw claim.
* app registration. Surfaced for the future `@RequireAdmin` /
* `@RequireRole(...)` guards (ADR-0020 admin module). Always
* present in the shape an empty array means the user has no
* app role on this app registration, not that the claim was
* unparseable.
*/
export interface AuthenticatedUser {
readonly oid: string;
@@ -63,7 +51,6 @@ export interface AuthenticatedUser {
readonly displayName: string;
readonly amr: readonly string[];
readonly roles: readonly string[];
readonly groups: readonly string[];
}
/**
@@ -225,18 +212,6 @@ export class AuthService {
? (claims['roles'] as unknown[]).filter((v): v is string => typeof v === 'string')
: [];
// `groups` is an optional claim (per ADR-0025 §"Sources of truth
// — Entra-side configuration"). Present only when the app
// registration sets `groupMembershipClaims: 'SecurityGroup'`.
// Empty array if the user is in zero security groups or the
// claim is not configured — both are valid "no functional
// roles" states the rest of the BFF must tolerate. Same
// string-filter as `roles` to defend against a non-string value
// smuggled in via a malformed token.
const groups = Array.isArray(claims['groups'])
? (claims['groups'] as unknown[]).filter((v): v is string => typeof v === 'string')
: [];
return {
oid: requireString(claims['oid'], 'oid'),
tid: requireString(claims['tid'], 'tid'),
@@ -250,7 +225,6 @@ export class AuthService {
: (result.account?.name ?? ''),
amr,
roles,
groups,
};
}
}
@@ -1,12 +0,0 @@
/**
* DI token for the lazily-loaded `EntraGroupToRoleResolver`.
*
* The resolver wraps a `Map<groupGuid, FunctionalRole>` parsed
* once at boot from `infra/<env>-tenant.entra.json` (per ADR-0025
* §"Sources of truth — Entra-side configuration"). The token is
* separate from the class import so the BFF can swap the
* implementation (an empty-map stub when no file is configured,
* a real one when `ENTRA_GROUP_MAP_PATH` resolves) without the
* consumers caring.
*/
export const ENTRA_GROUP_TO_ROLE_RESOLVER = Symbol('ENTRA_GROUP_TO_ROLE_RESOLVER');
@@ -1,359 +0,0 @@
import type { Logger } from 'nestjs-pino';
import {
EntraGroupToRoleResolver,
parseEntraGroupMap,
type FunctionalRole,
type Scope,
} from 'shared-auth';
import type { AuthenticatedUser } from './auth.service';
import { PrincipalBuilder } from './principal-builder';
import type { ScopeResolver } from './scope-resolver';
/**
* Mirrors the 24-entry `apf-role-*` catalogue: one synthetic GUID
* per slug, in catalogue order. The exact GUIDs do not matter to
* any assertion below what matters is that the test resolver
* maps them deterministically so a persona's `groups` claim can
* be assembled by slug.
*/
const ROLE_GUID: Record<FunctionalRole, string> = {
collaborateur: '00000000-0000-0000-0000-000000000101',
'chef-equipe': '00000000-0000-0000-0000-000000000102',
'chef-service': '00000000-0000-0000-0000-000000000103',
'directeur-etablissement': '00000000-0000-0000-0000-000000000104',
'directeur-territorial': '00000000-0000-0000-0000-000000000105',
rh: '00000000-0000-0000-0000-000000000106',
'responsable-paie': '00000000-0000-0000-0000-000000000107',
comptable: '00000000-0000-0000-0000-000000000108',
juriste: '00000000-0000-0000-0000-000000000109',
dpo: '00000000-0000-0000-0000-00000000010a',
rssi: '00000000-0000-0000-0000-00000000010b',
it: '00000000-0000-0000-0000-00000000010c',
formation: '00000000-0000-0000-0000-00000000010d',
qualite: '00000000-0000-0000-0000-00000000010e',
communication: '00000000-0000-0000-0000-00000000010f',
'elu-ca': '00000000-0000-0000-0000-000000000110',
'elu-cd': '00000000-0000-0000-0000-000000000111',
'elu-cd-president': '00000000-0000-0000-0000-000000000112',
'elu-cd-tresorier': '00000000-0000-0000-0000-000000000113',
'elu-cd-secretaire': '00000000-0000-0000-0000-000000000114',
delegue: '00000000-0000-0000-0000-000000000115',
benevole: '00000000-0000-0000-0000-000000000116',
'benevole-responsable': '00000000-0000-0000-0000-000000000117',
partenaire: '00000000-0000-0000-0000-000000000118',
};
/**
* The 19 personas provisioned in the `apfrd.onmicrosoft.com` test
* tenant on 2026-05-20, transcribed from
* `notes/test-tenant-role-assignments.md` (reverse view). The
* builder's contract is that these inputs round-trip to the
* expected `(privileges, roles, scopes)` triple when the spec
* passes, every persona we documented in the ADR is exercised
* end-to-end.
*
* Scopes are stubbed to `[{ kind: 'unrestricted' }]` for every
* persona in v1 per ADR-0025 §"Sources of truth apf_portal-side
* `user_scopes` table". The intended per-persona scopes
* (`etablissement:…`, `delegation:33`, ) land with the Prisma
* `user_scopes` table the next PR after this skeleton.
*/
interface Persona {
readonly label: string;
readonly username: string;
readonly privileges: readonly string[]; // raw `roles` claim values
readonly roles: readonly FunctionalRole[];
}
const PERSONAS: readonly Persona[] = [
{
label: 'admin',
username: 'admin@apfrd.onmicrosoft.com',
privileges: ['Portal.Admin'],
roles: ['collaborateur', 'rh'],
},
{
label: 'directeur-bordeaux',
username: 'directeur-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'directeur-complexe',
username: 'directeur-complexe@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'directeur-etablissement'],
},
{
label: 'rh-aquitaine',
username: 'rh-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'rh', 'formation'],
},
{
label: 'rh-siege',
username: 'rh-siege@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'],
},
{
label: 'collaborateur-simple',
username: 'collaborateur-simple@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur'],
},
{
label: 'tresorier-bordeaux',
username: 'tresorier-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-cd', 'elu-cd-tresorier'],
},
{
label: 'dpo',
username: 'dpo@apfrd.onmicrosoft.com',
privileges: ['Portal.DPO', 'Portal.Auditor'],
roles: ['collaborateur', 'dpo', 'qualite'],
},
{
label: 'it',
username: 'it@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'it'],
},
{
label: 'benevole-aquitaine',
username: 'benevole-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
// Catalogue order: delegue (governance) precedes benevole +
// benevole-responsable (volunteer). The resolver re-sorts on
// catalogue order regardless of the Entra claim's order.
roles: ['delegue', 'benevole', 'benevole-responsable'],
},
{
label: 'chef-equipe-bordeaux',
username: 'chef-equipe-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'chef-equipe'],
},
{
label: 'chef-service-bordeaux',
username: 'chef-service-bordeaux@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'chef-service'],
},
{
label: 'directeur-territorial-aquitaine',
username: 'directeur-territorial-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'directeur-territorial'],
},
{
label: 'juriste-siege',
username: 'juriste-siege@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'juriste'],
},
{
label: 'rssi',
username: 'rssi@apfrd.onmicrosoft.com',
privileges: ['Portal.SecurityOfficer'],
roles: ['collaborateur', 'rssi'],
},
{
label: 'communication-siege',
username: 'communication-siege@apfrd.onmicrosoft.com',
privileges: [],
roles: ['collaborateur', 'communication'],
},
{
label: 'elu-ca-national',
username: 'elu-ca-national@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-ca'],
},
{
label: 'president-cd-aquitaine',
username: 'president-cd-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-cd', 'elu-cd-president'],
},
{
label: 'secretaire-cd-aquitaine',
username: 'secretaire-cd-aquitaine@apfrd.onmicrosoft.com',
privileges: [],
roles: ['elu-cd', 'elu-cd-secretaire'],
},
];
function makeUser(p: Persona): AuthenticatedUser {
return {
oid: `oid-${p.label}`,
tid: 'tenant-1',
username: p.username,
displayName: p.label,
amr: ['pwd', 'mfa'],
roles: [...p.privileges],
groups: p.roles.map((slug) => ROLE_GUID[slug]),
};
}
/**
* Fixed identity used by every `build()` call in the spec. The
* provisioner is mocked at the `SessionEstablisher` test level; the
* `PrincipalBuilder` spec receives the UUIDs verbatim what matters
* here is that they round-trip onto `Principal.user.{id, personId}`.
*/
const TEST_IDENTITY = {
userId: '00000000-0000-0000-0000-000000000fff',
personId: '00000000-0000-0000-0000-000000000eee',
};
function makeBuilder(): {
builder: PrincipalBuilder;
scopeResolver: { resolve: jest.Mock };
logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock };
} {
const rawMap: Record<string, FunctionalRole> = {};
for (const [slug, guid] of Object.entries(ROLE_GUID) as Array<[FunctionalRole, string]>) {
rawMap[guid] = slug;
}
const resolver = new EntraGroupToRoleResolver(parseEntraGroupMap(rawMap));
const scopeResolver = {
resolve: jest.fn().mockResolvedValue([{ kind: 'unrestricted' } as Scope]),
};
const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
const builder = new PrincipalBuilder(
resolver,
scopeResolver as unknown as ScopeResolver,
logger as unknown as Logger,
);
return { builder, scopeResolver, logger };
}
describe('PrincipalBuilder — 19 test-tenant personas', () => {
for (const persona of PERSONAS) {
it(`builds the principal for ${persona.label}`, async () => {
const { builder } = makeBuilder();
const principal = await builder.build(makeUser(persona), TEST_IDENTITY);
expect(principal.privileges).toEqual(persona.privileges);
expect(principal.roles).toEqual(persona.roles);
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
expect(principal.user.entraOid).toBe(`oid-${persona.label}`);
expect(principal.user.tenantId).toBe('tenant-1');
// Real UUIDs from the provisioner, per ADR-0026 PR 1 — no
// longer the entraOid placeholder.
expect(principal.user.id).toBe(TEST_IDENTITY.userId);
expect(principal.user.personId).toBe(TEST_IDENTITY.personId);
// amr passes through verbatim — MFA freshness checks read it
// off the principal per ADR-0011.
expect(principal.amr).toEqual(['pwd', 'mfa']);
});
}
it('asserts the 19 personas cover all 4 privileges + 23 of 24 functional roles', () => {
// Coverage check baked into the spec so a regression that
// drops a role from a persona is caught here, not at PR review.
// The intentional gap is `partenaire` (placeholder per ADR-0025).
const privilegesUsed = new Set(PERSONAS.flatMap((p) => p.privileges));
expect(privilegesUsed).toEqual(
new Set(['Portal.Admin', 'Portal.Auditor', 'Portal.SecurityOfficer', 'Portal.DPO']),
);
const rolesUsed = new Set(PERSONAS.flatMap((p) => p.roles));
expect(rolesUsed.size).toBe(23);
expect(rolesUsed.has('partenaire')).toBe(false);
});
});
describe('PrincipalBuilder — edge cases', () => {
it('returns an empty privileges array when the user has no app role assignment', async () => {
const { builder } = makeBuilder();
const user = makeUser({
label: 'anon',
username: 'anon@example',
privileges: [],
roles: ['collaborateur'],
});
const principal = await builder.build(user, TEST_IDENTITY);
expect(principal.privileges).toEqual([]);
});
it('drops + warns on a claim value that is not a known privilege', async () => {
const { builder, logger } = makeBuilder();
const user = makeUser({
label: 'stale',
username: 'stale@example',
privileges: [],
roles: [],
});
// Force in a non-catalogue value the way an old assignment would.
const userWithDrift: AuthenticatedUser = {
...user,
roles: ['Portal.Admin', 'Portal.GhostRole'],
};
const principal = await builder.build(userWithDrift, TEST_IDENTITY);
expect(principal.privileges).toEqual(['Portal.Admin']);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'auth.unknown_privilege_claim',
value: 'Portal.GhostRole',
}),
'PrincipalBuilder',
);
});
it('drops + warns on an unknown group GUID (tenant misconfiguration)', async () => {
const { builder, logger } = makeBuilder();
const user = makeUser({
label: 'ghost-group',
username: 'ghost-group@example',
privileges: [],
roles: ['collaborateur'],
});
const userWithUnknownGroup: AuthenticatedUser = {
...user,
groups: [...user.groups, 'ffffffff-ffff-ffff-ffff-ffffffffffff'],
};
const principal = await builder.build(userWithUnknownGroup, TEST_IDENTITY);
expect(principal.roles).toEqual(['collaborateur']);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'auth.unknown_group_claim',
groupId: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
}),
'PrincipalBuilder',
);
});
it('builds an empty-permissions principal when the user has no groups or roles', async () => {
const { builder } = makeBuilder();
const user = makeUser({
label: 'empty',
username: 'empty@example',
privileges: [],
roles: [],
});
const principal = await builder.build(user, TEST_IDENTITY);
expect(principal.privileges).toEqual([]);
expect(principal.roles).toEqual([]);
// Scope resolver stub returns unrestricted — the v1 behaviour
// per ADR-0025 §331 until the user_scopes table lands.
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
});
it('asks the scope resolver to resolve by userId (ADR-0026 PR 1 seam change)', async () => {
const { builder, scopeResolver } = makeBuilder();
await builder.build(
makeUser({
label: 'sr',
username: 'sr@example',
privileges: [],
roles: ['collaborateur'],
}),
TEST_IDENTITY,
);
// Pre-ADR-0026 the resolver was called with { entraOid }; PR 1
// switches the seam to { userId } so PR 2's PrismaScopeResolver
// can key queries on the real User.id.
expect(scopeResolver.resolve).toHaveBeenCalledWith({ userId: TEST_IDENTITY.userId });
});
});
@@ -1,107 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { EntraGroupToRoleResolver, isPrivilege, type Principal, type Privilege } from 'shared-auth';
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
import { ScopeResolver } from './scope-resolver';
import type { AuthenticatedUser } from './auth.service';
/**
* Builds the session-resident `Principal` per
* [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Composes the three orthogonal axes once at sign-in:
* - **Privileges** pulled from the `roles` claim, filtered to
* the closed `PRIVILEGES` catalogue. Unknown values are
* dropped (with a WARN) rather than honoured: the BFF only
* reasons about catalogue privileges, anything else is
* either a tenant misconfiguration or a leftover from a v1+1
* experiment that should not silently grant access.
* - **Functional roles** resolved from the `groups` claim via
* `EntraGroupToRoleResolver`. Unknown GUIDs are logged at
* WARN (per ADR-0025 §"Sources of truth Entra-side
* configuration") and ignored.
* - **Scopes** resolved by `ScopeResolver` from the portal-side
* `user_scopes` table (schema landed in ADR-0026 PR 1; the
* `PrismaScopeResolver` consumer lands in PR 2). v1 stubs to
* `[{ kind: 'unrestricted' }]`.
*
* Built once per sign-in, not per request: the session payload
* carries the resolved `Principal` so guards can read it without
* re-doing the GUID lookup on every API call.
*
* The `identity` parameter (UUIDs from `PersonAndUserProvisioner`)
* populates `Principal.user.{id, personId}` with the real portal-
* side identifiers introduced in ADR-0026 PR 1 pre-PR-1 the BFF
* reused the Entra `oid` as a placeholder.
*/
@Injectable()
export class PrincipalBuilder {
constructor(
@Inject(ENTRA_GROUP_TO_ROLE_RESOLVER)
private readonly groupToRole: EntraGroupToRoleResolver,
private readonly scopes: ScopeResolver,
private readonly logger: Logger,
) {}
async build(
user: AuthenticatedUser,
identity: { userId: string; personId: string },
): Promise<Principal> {
const privileges = filterPrivileges(user.roles, user.oid, this.logger);
const roles = this.groupToRole.resolve(user.groups, (groupId: string) => {
this.logger.warn(
{
event: 'auth.unknown_group_claim',
oid: user.oid,
groupId,
},
'PrincipalBuilder',
);
});
const scopes = await this.scopes.resolve({ userId: identity.userId });
return {
user: {
id: identity.userId,
personId: identity.personId,
entraOid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
},
privileges,
roles,
scopes,
amr: user.amr,
};
}
}
function filterPrivileges(
rawRoles: readonly string[],
oid: string,
logger: Logger,
): ReadonlyArray<Privilege> {
const out: Privilege[] = [];
for (const value of rawRoles) {
if (isPrivilege(value)) {
out.push(value);
} else {
// A value in the `roles` claim that is not a known privilege
// is either a leftover from a different app registration, a
// typo in the Entra manifest, or a future privilege not yet
// in the catalogue. None of those should silently grant
// access — drop with a WARN so an operator can investigate.
logger.warn(
{
event: 'auth.unknown_privilege_claim',
oid,
value,
},
'PrincipalBuilder',
);
}
}
return out;
}
@@ -1,115 +0,0 @@
import type { Request } from 'express';
import type { Principal, Scope } from 'shared-auth';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
const PRINCIPAL: Principal = {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: ['Portal.Admin'],
roles: ['rh'],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
function makeReq(session: Record<string, unknown> | undefined): Request {
return { session } as unknown as Request;
}
describe('readSessionPrincipal', () => {
it('returns null when the request has no session at all', () => {
expect(readSessionPrincipal(makeReq(undefined))).toBeNull();
});
it('returns null when the session has neither principal nor legacy user', () => {
expect(readSessionPrincipal(makeReq({}))).toBeNull();
});
it('returns the principal verbatim when session.principal is set', () => {
const out = readSessionPrincipal(makeReq({ principal: PRINCIPAL }));
expect(out).toBe(PRINCIPAL);
});
describe('legacy bridge — session.user only (pre-ADR-0025 sessions)', () => {
it('synthesises a principal carrying the Portal.* values as privileges', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: ['pwd', 'mfa'],
roles: ['Portal.Admin', 'Portal.DPO', 'editor', 'auditor'],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out).not.toBeNull();
expect(out!.privileges).toEqual(['Portal.Admin', 'Portal.DPO']);
expect(out!.user.entraOid).toBe('legacy-oid');
expect(out!.user.tenantId).toBe('tenant-7');
});
it('mirrors entraOid into user.id and user.personId (ADR-0026 placeholder)', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: [],
roles: [],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out!.user.id).toBe('legacy-oid');
expect(out!.user.personId).toBe('legacy-oid');
});
it('legacy sessions get no functional roles and a coarse unrestricted scope', () => {
const req = makeReq({
user: {
oid: 'legacy-oid',
tid: 'tenant-7',
username: 'legacy@example',
displayName: 'Legacy Joe',
amr: [],
roles: ['Portal.Admin'],
groups: [],
},
});
const out = readSessionPrincipal(req);
expect(out!.roles).toEqual([]);
expect(out!.scopes).toEqual([{ kind: 'unrestricted' }]);
});
});
});
describe('scopesToAuditStrings', () => {
it('renders implicit scopes as the kind verbatim', () => {
const scopes: Scope[] = [{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }];
expect(scopesToAuditStrings(scopes)).toEqual(['self', 'siege', 'unrestricted']);
});
it('renders value-bearing scopes as kind:value', () => {
const scopes: Scope[] = [
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
];
expect(scopesToAuditStrings(scopes)).toEqual([
'etablissement:0330800013',
'delegation:33',
'region:75',
]);
});
it('returns an empty array on an empty scope list', () => {
expect(scopesToAuditStrings([])).toEqual([]);
});
});
@@ -1,74 +0,0 @@
import type { Request } from 'express';
import type { Principal, Scope } from 'shared-auth';
/**
* Reads the authorization `Principal` off the express session,
* coalescing the legacy session-shape carryover so a session that
* was minted before the ADR-0025 implementation landed still works
* for the duration of its 12 h absolute TTL.
*
* Returns `null` when no session is present at all (the guard
* should answer 401 the user is anonymous). Returns a
* synthesised `Principal` derived from `session.user` when the
* session is authenticated but predates the `principal` field
* the synthesised principal carries the `Portal.*` privileges
* from the legacy `roles` claim, no functional roles (the
* `groups` claim was unconfigured at the time), and an
* `unrestricted` scope. After every persisted session has cycled
* through the absolute-timeout window post-deploy, this fallback
* is dead code.
*/
export function readSessionPrincipal(req: Request): Principal | null {
const session = req.session;
if (session === undefined) {
return null;
}
if (session.principal !== undefined) {
return session.principal;
}
const user = session.user;
if (user === undefined) {
return null;
}
// Legacy session bridge. Filter `user.roles` through a generic
// `Portal.*` heuristic rather than re-importing `isPrivilege`
// here — the file's whole purpose is graceful degradation; if
// the legacy claim carries a non-catalogue Portal.X the matcher
// downstream just returns false on it.
const portalPrivileges = user.roles.filter((r) => r.startsWith('Portal.'));
const fallback: Principal = {
user: {
id: user.oid,
personId: user.oid,
entraOid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
},
privileges: portalPrivileges as Principal['privileges'],
roles: [],
scopes: [{ kind: 'unrestricted' } satisfies Scope],
amr: user.amr,
};
return fallback;
}
/**
* Format a scope list as the `string[]` shape the
* `AuthorizationDeniedInput.held` audit field expects:
* `unrestricted` / `self` / `siege` ride as-is;
* `etablissement:<finess>` / `delegation:<dept>` / `region:<insee>`
* concatenate kind and value. Mirrors `PrincipalProjector` in
* spirit but is independent (the audit module must not depend on
* the AI-bridge projector different surfaces).
*/
export function scopesToAuditStrings(scopes: ReadonlyArray<Scope>): string[] {
return scopes.map((s) => {
if (s.kind === 'etablissement' || s.kind === 'delegation' || s.kind === 'region') {
return `${s.kind}:${s.value}`;
}
return s.kind;
});
}
@@ -1,129 +0,0 @@
import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma';
import { PrismaScopeResolver, toScope } from './prisma-scope-resolver';
interface PrismaStub {
userScope: {
findMany: jest.Mock;
};
}
function makeLogger() {
return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
log: jest.Mock;
warn: jest.Mock;
error: jest.Mock;
};
}
function makeSubject(opts?: { findMany?: jest.Mock }): {
resolver: PrismaScopeResolver;
prisma: PrismaStub;
logger: ReturnType<typeof makeLogger>;
} {
const prisma: PrismaStub = {
userScope: {
findMany: opts?.findMany ?? jest.fn().mockResolvedValue([]),
},
};
const logger = makeLogger();
const resolver = new PrismaScopeResolver(prisma as unknown as PrismaService, logger);
return { resolver, prisma, logger };
}
describe('PrismaScopeResolver.resolve — query shape', () => {
it('filters by userId and excludes rows whose expiresAt has passed', async () => {
const { resolver, prisma } = makeSubject();
await resolver.resolve({ userId: 'user-uuid-1' });
expect(prisma.userScope.findMany).toHaveBeenCalledTimes(1);
const args = prisma.userScope.findMany.mock.calls[0]?.[0] as {
where: {
userId: string;
OR: ReadonlyArray<{ expiresAt: Date | { gt: Date } | null }>;
};
select: { kind: boolean; value: boolean };
};
expect(args.where.userId).toBe('user-uuid-1');
expect(args.where.OR).toHaveLength(2);
expect(args.where.OR[0]).toEqual({ expiresAt: null });
// The second predicate carries a `gt: <Date>` matching "expiresAt > NOW()".
expect(args.where.OR[1]).toMatchObject({ expiresAt: { gt: expect.any(Date) } });
expect(args.select).toEqual({ kind: true, value: true });
});
it('returns an empty array when the user has no scope rows', async () => {
const { resolver } = makeSubject();
const result = await resolver.resolve({ userId: 'user-uuid-1' });
expect(result).toEqual([]);
});
});
describe('PrismaScopeResolver.resolve — row-to-Scope mapping', () => {
it('maps valueless kinds to `{ kind }`', async () => {
const findMany = jest.fn().mockResolvedValue([
{ kind: 'self', value: '' },
{ kind: 'siege', value: '' },
{ kind: 'unrestricted', value: '' },
]);
const { resolver } = makeSubject({ findMany });
const result = await resolver.resolve({ userId: 'u' });
expect(result).toEqual([{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }]);
});
it('maps value-bearing kinds to `{ kind, value }`', async () => {
const findMany = jest.fn().mockResolvedValue([
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
]);
const { resolver } = makeSubject({ findMany });
const result = await resolver.resolve({ userId: 'u' });
expect(result).toEqual([
{ kind: 'etablissement', value: '0330800013' },
{ kind: 'delegation', value: '33' },
{ kind: 'region', value: '75' },
]);
});
it('skips + warns on off-catalogue kinds (defense in depth — drift gate is the canonical guard)', async () => {
const findMany = jest.fn().mockResolvedValue([
{ kind: 'self', value: '' },
{ kind: 'phantom-kind', value: '' },
{ kind: 'unrestricted', value: '' },
]);
const { resolver, logger } = makeSubject({ findMany });
const result = await resolver.resolve({ userId: 'u' });
expect(result).toEqual([{ kind: 'self' }, { kind: 'unrestricted' }]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
event: 'scope_resolver.unknown_kind',
userId: 'u',
kind: 'phantom-kind',
}),
'PrismaScopeResolver',
);
});
});
describe('toScope — pure mapping helper', () => {
it('returns null for off-catalogue kinds', () => {
expect(toScope('phantom-kind', '')).toBeNull();
expect(toScope('', '')).toBeNull();
expect(toScope('Etablissement', '0330800013')).toBeNull(); // case-sensitive
});
it('drops the value for valueless kinds (even if a stale value is in the row)', () => {
expect(toScope('self', 'stale')).toEqual({ kind: 'self' });
expect(toScope('siege', 'stale')).toEqual({ kind: 'siege' });
expect(toScope('unrestricted', 'stale')).toEqual({ kind: 'unrestricted' });
});
it('preserves the value verbatim for value-bearing kinds', () => {
expect(toScope('etablissement', '0330800013')).toEqual({
kind: 'etablissement',
value: '0330800013',
});
expect(toScope('delegation', '2A')).toEqual({ kind: 'delegation', value: '2A' });
expect(toScope('region', '75')).toEqual({ kind: 'region', value: '75' });
});
});
@@ -1,91 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { PrismaService } from 'nestjs-prisma';
import { isScopeKind, type Scope } from 'shared-auth';
import { ScopeResolver } from './scope-resolver';
/**
* `PrismaScopeResolver` production implementation of
* [`ScopeResolver`](scope-resolver.ts) backed by the `user_scopes`
* table introduced in ADR-0026 PR 1.
*
* Replaces `StubScopeResolver` (which always returned
* `[{ kind: 'unrestricted' }]`) per ADR-0025 §"Sources of truth
* apf_portal-side `user_scopes` table". Called once per sign-in by
* `PrincipalBuilder.build`; the resolved scopes ride on the
* session-resident `Principal` so guards never re-query at request
* time.
*
* **Expiry handling.** Rows with a non-null `expiresAt` in the past
* are filtered server-side (`expiresAt IS NULL OR expiresAt > NOW()`).
* The unique-tuple constraint on `(userId, kind, value)` means a
* persona with a `delegation:33` scope that just expired can be
* re-granted by inserting a new row the resolver picks up the
* fresh one on the next sign-in. No background cleanup job in v1;
* expired rows are harmless until either a re-grant fires the
* unique constraint or an admin manually purges them.
*
* **Catalogue defense.** Rows with an unknown `kind` (off-catalogue
* should be impossible given the drift gate, but possible if a
* future migration writes raw SQL or a future operator runs an
* unguarded INSERT) are skipped + logged. Defensive read; the write
* path is the canonical place to enforce catalogue membership.
*/
@Injectable()
export class PrismaScopeResolver extends ScopeResolver {
constructor(
private readonly prisma: PrismaService,
private readonly logger: Logger,
) {
super();
}
async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
const now = new Date();
const rows = await this.prisma.userScope.findMany({
where: {
userId,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { kind: true, value: true },
});
const scopes: Scope[] = [];
for (const row of rows) {
const scope = toScope(row.kind, row.value);
if (scope === null) {
this.logger.warn(
{
event: 'scope_resolver.unknown_kind',
userId,
kind: row.kind,
},
'PrismaScopeResolver',
);
continue;
}
scopes.push(scope);
}
return scopes;
}
}
/**
* Map a `(kind, value)` DB row to a typed `Scope`. Returns null for
* off-catalogue kinds so the resolver can log + skip them without a
* throw. Exported for the spec call sites should always go through
* the resolver.
*/
export function toScope(kind: string, value: string): Scope | null {
if (!isScopeKind(kind)) return null;
switch (kind) {
case 'self':
case 'siege':
case 'unrestricted':
return { kind };
case 'etablissement':
case 'delegation':
case 'region':
return { kind, value };
}
}
@@ -53,7 +53,6 @@ const USER = {
displayName: 'Jane',
amr: ['pwd', 'mfa'],
roles: ['admin'],
groups: [],
};
describe('RequireMfaGuard', () => {
@@ -1,33 +0,0 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { Privilege } from 'shared-auth';
import { RequirePrivilegeGuard } from './require-privilege.guard';
/**
* Reflector metadata key carrying the privilege list a route
* requires. Re-exported by the guard module so the two stay in
* lockstep.
*/
export const REQUIRE_PRIVILEGE_METADATA = 'auth:require-privilege';
/**
* `@RequirePrivilege('Portal.X', ...)` gates a route on
* `principal.privileges` containing at least one of the listed
* `Portal.*` values per [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Multiple slugs compose as **OR** within the decorator. Stacking
* `@RequirePrivilege` with another guard (`@RequireRole`,
* `@RequireMfa`) **AND**-combines at the Nest level each guard
* runs separately and a single denial stops the request.
*
* Calling with an empty privilege list is rejected at the type
* level by the `[Privilege, ...Privilege[]]` tuple a route can
* not opt out of authorization by passing zero values.
*/
export function RequirePrivilege(
...privileges: [Privilege, ...Privilege[]]
): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(REQUIRE_PRIVILEGE_METADATA, privileges),
UseGuards(RequirePrivilegeGuard),
);
}
@@ -1,133 +0,0 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { Principal, Privilege } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequirePrivilegeGuard } from './require-privilege.guard';
function principalFor(privileges: readonly Privilege[]): Principal {
return {
user: {
id: 'user-1',
personId: 'user-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges,
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
metadata: readonly Privilege[] | undefined;
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/audit',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'AuditController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { metadata: readonly Privilege[] | undefined; audit?: jest.Mock }): {
guard: RequirePrivilegeGuard;
audit: jest.Mock;
} {
const audit = opts.audit ?? jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequirePrivilegeGuard(reflector, writer), audit };
}
describe('RequirePrivilegeGuard', () => {
it('passes when no metadata is set (decorator absent — guard wired but route unprotected)', async () => {
const { guard } = makeGuard({ metadata: undefined });
const { ctx } = makeContext({ metadata: undefined });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({ metadata: ['Portal.Admin'], session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal carries the required privilege', async () => {
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor(['Portal.Admin']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('OR-combines multiple required privileges (matches any one)', async () => {
const { guard } = makeGuard({
metadata: ['Portal.Auditor', 'Portal.Admin'],
});
const { ctx } = makeContext({
metadata: ['Portal.Auditor', 'Portal.Admin'],
session: { principal: principalFor(['Portal.Auditor']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the principal lacks every required privilege', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor(['Portal.Auditor']) },
method: 'POST',
url: '/api/admin/cms/pages',
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'POST /api/admin/cms/pages',
kind: 'privilege',
required: ['Portal.Admin'],
held: ['Portal.Auditor'],
});
});
it('audits the held privileges as the principal saw them, not the legacy roles claim', async () => {
const { guard, audit } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor([]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ held: [] }));
});
it('returns a generic forbidden body (no privilege hint leaks)', async () => {
const { guard } = makeGuard({ metadata: ['Portal.Admin'] });
const { ctx } = makeContext({
metadata: ['Portal.Admin'],
session: { principal: principalFor([]) },
});
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(body['message']).toBe('Forbidden');
expect(JSON.stringify(body)).not.toContain('Portal.Admin');
}
});
});
@@ -1,81 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { type Privilege, principalHasAnyPrivilege } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from './principal-extractor';
import { REQUIRE_PRIVILEGE_METADATA } from './require-privilege.decorator';
/**
* `RequirePrivilegeGuard` enforces ADR-0025 privilege gates.
*
* Contract (matches `AdminRoleGuard` for symmetry):
*
* - **No session 401 `unauthenticated`.** Anonymous traffic is
* noise; no audit row, the SPA kicks off the login flow.
*
* - **Session but missing every required privilege 403 + audit.**
* The user *is* authenticated; they just are not authorised.
* Audit row: `auth.authorization_denied`, `kind=privilege`,
* `required` = what the route asked for, `held` = what the
* principal actually carries.
*
* - **Session with at least one required privilege pass.**
*
* The structured error envelope per [ADR-0021](../../../../docs/decisions/0021-phase-2-security-baseline.md):
* the body is `{ error: { code: 'forbidden', message, traceId } }`
* with a generic message no role / privilege hint, so an
* attacker cannot use 403 responses to enumerate which privilege
* a route gates.
*/
@Injectable()
export class RequirePrivilegeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<readonly Privilege[]>(
REQUIRE_PRIVILEGE_METADATA,
[context.getHandler(), context.getClass()],
);
if (required === undefined || required.length === 0) {
// No metadata = guard wired but route un-annotated. Treat as
// pass-through rather than guess at the intent — the decorator
// is what carries the privilege list.
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
if (principalHasAnyPrivilege(principal, required)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'privilege',
required,
held: [...principal.privileges],
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
@@ -1,29 +0,0 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { FunctionalRole } from 'shared-auth';
import { RequireRoleGuard } from './require-role.guard';
/**
* Reflector metadata key carrying the functional-role list a
* route requires.
*/
export const REQUIRE_ROLE_METADATA = 'auth:require-role';
/**
* `@RequireRole('rh', ...)` gates a route on `principal.roles`
* containing at least one of the listed `apf-role-*` slugs per
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Multiple slugs compose as **OR** within the decorator. Stacking
* `@RequireRole` with another guard (`@RequirePrivilege`,
* `@RequireScope`, `@RequireMfa`) **AND**-combines at the Nest
* level.
*
* The empty-list call is rejected at the type level by the
* `[FunctionalRole, ...FunctionalRole[]]` tuple a route can
* not opt out of authorization by passing zero values.
*/
export function RequireRole(
...roles: [FunctionalRole, ...FunctionalRole[]]
): ClassDecorator & MethodDecorator {
return applyDecorators(SetMetadata(REQUIRE_ROLE_METADATA, roles), UseGuards(RequireRoleGuard));
}
@@ -1,111 +0,0 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { FunctionalRole, Principal } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import { RequireRoleGuard } from './require-role.guard';
function principalFor(roles: readonly FunctionalRole[]): Principal {
return {
user: {
id: 'user-1',
personId: 'user-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles,
scopes: [{ kind: 'unrestricted' }],
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/hr/payroll',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'HrController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { metadata: readonly FunctionalRole[] | undefined }): {
guard: RequireRoleGuard;
audit: jest.Mock;
} {
const audit = jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.metadata),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireRoleGuard(reflector, writer), audit };
}
describe('RequireRoleGuard', () => {
it('passes when no metadata is set', async () => {
const { guard } = makeGuard({ metadata: undefined });
const { ctx } = makeContext({});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal carries the required role', async () => {
const { guard } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: { principal: principalFor(['rh']) } });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('OR-combines multiple required roles', async () => {
const { guard } = makeGuard({ metadata: ['rh', 'responsable-paie', 'comptable'] });
const { ctx } = makeContext({
session: { principal: principalFor(['comptable']) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the principal lacks every required role', async () => {
const { guard, audit } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({
session: { principal: principalFor(['collaborateur']) },
method: 'GET',
url: '/api/hr/payroll',
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'GET /api/hr/payroll',
kind: 'role',
required: ['rh'],
held: ['collaborateur'],
});
});
it('returns a generic forbidden body (no role hint leaks)', async () => {
const { guard } = makeGuard({ metadata: ['rh'] });
const { ctx } = makeContext({ session: { principal: principalFor([]) } });
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(JSON.stringify(body)).not.toContain('rh');
}
});
});
@@ -1,65 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { type FunctionalRole, principalHasAnyRole } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal } from './principal-extractor';
import { REQUIRE_ROLE_METADATA } from './require-role.decorator';
/**
* `RequireRoleGuard` enforces ADR-0025 functional-role gates.
*
* Same contract as `RequirePrivilegeGuard` with the role axis as
* the source of truth: 401 if anonymous, 403 + audit if
* authenticated but missing every required role, pass otherwise.
* Multiple roles on the decorator are OR-combined; stacking with
* other decorators is AND-combined at the Nest layer.
*/
@Injectable()
export class RequireRoleGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<readonly FunctionalRole[]>(
REQUIRE_ROLE_METADATA,
[context.getHandler(), context.getClass()],
);
if (required === undefined || required.length === 0) {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
if (principalHasAnyRole(principal, required)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'role',
required,
held: [...principal.roles],
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
@@ -1,52 +0,0 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import type { Request } from 'express';
import type { ScopableResource } from 'shared-auth';
import { RequireScopeGuard } from './require-scope.guard';
/**
* Reflector metadata key carrying the per-route resource
* extractor. The metadata value is the extractor function
* itself `Reflect.metadata` happily stores callables and the
* guard invokes it on every request.
*/
export const REQUIRE_SCOPE_METADATA = 'auth:require-scope';
/**
* Extractor signature consumed by `@RequireScope`. Receives the
* incoming Express request, returns the resource descriptor the
* matcher will compare against the principal's scopes. May be
* async a typical extractor loads the resource by
* `@Param('id')` from Prisma, denormalises its parentage chain
* (`etablissementFiness` / `delegationCode` / `regionCode`), and
* returns the descriptor.
*
* Returning `null` from the extractor means "the resource the
* route protects could not be identified" (a 404 in disguise)
* the guard treats it as a denial and emits the audit row with
* `required: []`. The route handler still runs if the guard
* lets it through; routes that want a true 404 should surface
* it from the handler.
*/
export type RequireScopeExtractor = (
req: Request,
) => ScopableResource | null | Promise<ScopableResource | null>;
/**
* `@RequireScope(req => …)` gates a route on
* `principalCoversResource(principal, extractor(req))` per
* [ADR-0025 §"Guard surface"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* The extractor receives the Express request typically pulls
* an id from `req.params`, loads the resource, returns its
* scope-relevant chain (FINESS / delegation / region). The guard
* does not assume the extractor is cheap routes that load the
* resource a second time inside the handler should pass the
* Prisma instance via a request-scoped CLS or DI, not via the
* extractor.
*/
export function RequireScope(extractor: RequireScopeExtractor): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(REQUIRE_SCOPE_METADATA, extractor),
UseGuards(RequireScopeGuard),
);
}
@@ -1,156 +0,0 @@
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import type { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import type { Principal, ScopableResource, Scope } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import type { RequireScopeExtractor } from './require-scope.decorator';
import { RequireScopeGuard } from './require-scope.guard';
function principalFor(scopes: readonly Scope[]): Principal {
return {
user: {
id: 'user-1',
personId: 'person-1',
entraOid: 'oid-1',
tenantId: 'tenant-1',
displayName: 'Alice',
},
privileges: [],
roles: [],
scopes,
amr: ['pwd', 'mfa'],
};
}
function makeContext(opts: {
session?: { principal?: Principal };
method?: string;
url?: string;
}): { ctx: ExecutionContext; req: Request } {
const req = {
session: opts.session,
method: opts.method ?? 'GET',
originalUrl: opts.url ?? '/api/etablissements/0330800013',
} as unknown as Request;
const ctx = {
switchToHttp: () => ({ getRequest: <T>() => req as T }),
getHandler: () => 'handler',
getClass: () => 'EtablissementController',
} as unknown as ExecutionContext;
return { ctx, req };
}
function makeGuard(opts: { extractor: RequireScopeExtractor | undefined }): {
guard: RequireScopeGuard;
audit: jest.Mock;
} {
const audit = jest.fn().mockResolvedValue(undefined);
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(opts.extractor),
} as unknown as Reflector;
const writer = { authorizationDenied: audit } as unknown as AuditWriter;
return { guard: new RequireScopeGuard(reflector, writer), audit };
}
describe('RequireScopeGuard', () => {
it('passes when no extractor metadata is set', async () => {
const { guard } = makeGuard({ extractor: undefined });
const { ctx } = makeContext({});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 401 when no session is present', async () => {
const { guard, audit } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800013' }),
});
const { ctx } = makeContext({ session: undefined });
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException);
expect(audit).not.toHaveBeenCalled();
});
it('passes when the principal scope covers the resource', async () => {
const { guard } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800013' }),
});
const { ctx } = makeContext({
session: {
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
},
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('honours async extractors (Prisma lookup pattern)', async () => {
const { guard } = makeGuard({
extractor: async () => Promise.resolve({ delegationCode: '33' }),
});
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'delegation', value: '33' }]) },
});
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
it('throws 403 + audits when the resource is outside the principal scopes', async () => {
const { guard, audit } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800021' }),
});
const { ctx } = makeContext({
session: {
principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]),
},
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith({
actor: { oid: 'oid-1' },
attemptedRoute: 'GET /api/etablissements/0330800013',
kind: 'scope',
required: ['etablissement:0330800021'],
held: ['etablissement:0330800013'],
});
});
it('throws 403 + audits with empty required when the extractor returns null', async () => {
const { guard, audit } = makeGuard({ extractor: () => null });
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'unrestricted' }]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'scope', required: [] }));
});
it('describes the resource correctly when it carries the full parentage chain', async () => {
const resource: ScopableResource = {
etablissementFiness: '0330800021',
delegationCode: '33',
regionCode: '75',
};
const { guard, audit } = makeGuard({ extractor: () => resource });
const { ctx } = makeContext({
session: { principal: principalFor([{ kind: 'region', value: '11' }]) },
});
await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
required: ['etablissement:0330800021', 'delegation:33', 'region:75'],
held: ['region:11'],
}),
);
});
it('returns a generic forbidden body (no resource fingerprint leaks)', async () => {
const { guard } = makeGuard({
extractor: () => ({ etablissementFiness: '0330800021' }),
});
const { ctx } = makeContext({
session: { principal: principalFor([]) },
});
try {
await guard.canActivate(ctx);
throw new Error('guard did not throw');
} catch (err) {
const body = (err as ForbiddenException).getResponse() as Record<string, unknown>;
expect(body['code']).toBe('forbidden');
expect(JSON.stringify(body)).not.toContain('0330800021');
}
});
});
@@ -1,119 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { principalCoversResource, type ScopableResource } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator';
/**
* `RequireScopeGuard` enforces ADR-0025 scope gates.
*
* Contract
* --------
* - **No session 401.** Same posture as the privilege / role
* guards.
* - **Extractor returned `null` 403 + audit.** The route's
* "what resource am I protecting?" question has no answer for
* this request; deny is the safe direction. Audit row carries
* `required: []` so an auditor can spot the missing-resource
* pattern.
* - **No matching scope 403 + audit.** Standard denial; audit
* captures the resource descriptor as `required` and the
* principal's actual scope list as `held`.
* - **Matching scope pass.**
*
* Like the other two new guards, the response body is the
* ADR-0021 structured-error envelope with a generic `forbidden`
* code no resource fingerprint leaks back to the caller.
*/
@Injectable()
export class RequireScopeGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditWriter,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const extractor = this.reflector.getAllAndOverride<RequireScopeExtractor>(
REQUIRE_SCOPE_METADATA,
[context.getHandler(), context.getClass()],
);
if (extractor === undefined) {
return true;
}
const req = context.switchToHttp().getRequest<Request>();
const principal = readSessionPrincipal(req);
if (principal === null) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'Unauthenticated',
});
}
const resource = await extractor(req);
if (resource === null) {
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'scope',
required: [],
held: scopesToAuditStrings(principal.scopes),
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
if (principalCoversResource(principal, resource)) {
return true;
}
await this.audit.authorizationDenied({
actor: { oid: principal.user.entraOid },
attemptedRoute: `${req.method} ${req.originalUrl}`,
kind: 'scope',
required: describeResource(resource),
held: scopesToAuditStrings(principal.scopes),
});
throw new ForbiddenException({
code: 'forbidden',
message: 'Forbidden',
});
}
}
/**
* Serialise the resource descriptor into the `string[]` shape the
* audit row's `required` field expects. Mirrors the `scope:value`
* convention of {@link scopesToAuditStrings} so the two sides of
* the audit row read consistently easy `requiredVs.held` diff
* in a log dashboard.
*/
function describeResource(resource: ScopableResource): string[] {
const out: string[] = [];
if (resource.personId !== undefined) {
out.push(`self:${resource.personId}`);
}
if (resource.etablissementFiness !== undefined) {
out.push(`etablissement:${resource.etablissementFiness}`);
}
if (resource.delegationCode !== undefined) {
out.push(`delegation:${resource.delegationCode}`);
}
if (resource.regionCode !== undefined) {
out.push(`region:${resource.regionCode}`);
}
if (resource.isSiege === true) {
out.push('siege');
}
return out;
}
@@ -1,49 +0,0 @@
import { Injectable } from '@nestjs/common';
import type { Scope } from 'shared-auth';
/**
* Resolves the portal-side scopes for a user being signed in.
*
* Per [ADR-0025 §"Sources of truth apf_portal-side `user_scopes`
* table"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md),
* scopes are *not* carried by Entra: they live in the portal-side
* `user_scopes` Prisma table (per ADR-0026 PR 1's schema), keyed on
* `User.id` (UUID). Each row materialises one `Scope` entry on the
* session-resident principal.
*
* The seam is here in v1 so ADR-0026 PR 2 (the `PrismaScopeResolver`
* landing the seed + the `/admin/users/:id/scopes` screen) replaces
* only the implementation, leaving the call-site in
* `PrincipalBuilder` untouched. The v1 stub (`StubScopeResolver`)
* always returns `[{ kind: 'unrestricted' }]` per ADR-0025 §331.
*
* Note: the `input` shape switched from `{ entraOid }` to `{ userId }`
* with ADR-0026 PR 1 (Person + User + UserScope schema landed). The
* stub ignores its argument either way; the change is the seam for
* PR 2's Prisma resolver, which keys queries on `User.id`.
*/
export abstract class ScopeResolver {
abstract resolve(input: { userId: string }): Promise<ReadonlyArray<Scope>>;
}
/**
* v1 stub returns `unrestricted` for every user. Per ADR-0025
* §"Sources of truth — apf_portal-side `user_scopes` table" the
* real implementation queries `user_scopes WHERE userId = ?`; that
* lands as `PrismaScopeResolver` in ADR-0026 PR 2 alongside the
* test-tenant seed.
*
* Deliberately not an in-memory hard-coded persona-keyed map: the
* 19 test personas have *intended* scopes (see
* `notes/test-tenant-role-assignments.md`) but those scopes have
* no guard consuming them yet, so per-persona stub data would be
* write-only documentation. ADR-0026 PR 2 populates the table; this
* class is replaced by a Prisma-backed implementation in the same
* change.
*/
@Injectable()
export class StubScopeResolver extends ScopeResolver {
override resolve(): Promise<ReadonlyArray<Scope>> {
return Promise.resolve([{ kind: 'unrestricted' }]);
}
}
@@ -1,12 +1,9 @@
import type { Request, Response } from 'express';
import type { Logger } from 'nestjs-pino';
import type { Principal } from 'shared-auth';
import type { AuditWriter } from '../audit/audit.service';
import type { UserSessionIndexService } from '../session/user-session-index.service';
import type { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
import type { UserDirectoryService } from '../users/user-directory.service';
import type { AuthenticatedUser } from './auth.service';
import type { PrincipalBuilder } from './principal-builder';
import { SessionEstablisher } from './session-establisher.service';
const USER: AuthenticatedUser = {
@@ -16,7 +13,6 @@ const USER: AuthenticatedUser = {
displayName: 'Jane Doe',
amr: ['pwd', 'mfa'],
roles: [],
groups: [],
};
function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
@@ -56,30 +52,9 @@ interface Fixture {
index: { add: jest.Mock; remove: jest.Mock; list: jest.Mock };
audit: { signIn: jest.Mock; signOut: jest.Mock };
directory: { recordSignIn: jest.Mock };
provisioner: { ensureUser: jest.Mock };
logger: ReturnType<typeof makeLoggerStub>;
principalBuilder: { build: jest.Mock };
}
const PROVISIONED_IDENTITY = {
userId: 'user-uuid-1',
personId: 'person-uuid-1',
};
const STUB_PRINCIPAL: Principal = {
user: {
id: PROVISIONED_IDENTITY.userId,
personId: PROVISIONED_IDENTITY.personId,
entraOid: USER.oid,
tenantId: USER.tid,
displayName: USER.displayName,
},
privileges: [],
roles: [],
scopes: [{ kind: 'unrestricted' }],
amr: USER.amr,
};
function makeFixture(): Fixture {
const index = {
add: jest.fn().mockResolvedValue(undefined),
@@ -93,22 +68,14 @@ function makeFixture(): Fixture {
const directory = {
recordSignIn: jest.fn().mockResolvedValue(undefined),
};
const provisioner = {
ensureUser: jest.fn().mockResolvedValue(PROVISIONED_IDENTITY),
};
const logger = makeLoggerStub();
const principalBuilder = {
build: jest.fn().mockResolvedValue(STUB_PRINCIPAL),
};
const est = new SessionEstablisher(
logger as unknown as Logger,
index as unknown as UserSessionIndexService,
audit as unknown as AuditWriter,
directory as unknown as UserDirectoryService,
provisioner as unknown as PersonAndUserProvisioner,
principalBuilder as unknown as PrincipalBuilder,
);
return { est, index, audit, directory, provisioner, logger, principalBuilder };
return { est, index, audit, directory, logger };
}
describe('SessionEstablisher.establish', () => {
@@ -130,56 +97,6 @@ describe('SessionEstablisher.establish', () => {
expect((sess['csrfToken'] as string).length).toBeGreaterThan(20);
});
it('provisions Person + User before building the Principal (ADR-0026 §Lifecycle)', async () => {
const { est, provisioner } = makeFixture();
const req = makeReqStub();
const res = makeResStub();
await est.establish({ user: USER, req, res, surface: 'user' });
expect(provisioner.ensureUser).toHaveBeenCalledWith({
oid: USER.oid,
tenantId: USER.tid,
displayName: USER.displayName,
// Entra preferred_username (= AuthenticatedUser.username) maps
// to Person.email per ADR-0026 §"Lifecycle".
email: USER.username,
});
});
it('builds the authorization principal with the provisioned UUIDs and stamps mfaVerifiedAt (ADR-0025 + ADR-0026)', async () => {
const { est, provisioner, principalBuilder } = makeFixture();
const req = makeReqStub();
const res = makeResStub();
await est.establish({ user: USER, req, res, surface: 'user' });
expect(principalBuilder.build).toHaveBeenCalledWith(USER, PROVISIONED_IDENTITY);
// Order: provisioner MUST run before principalBuilder so the
// build call has real UUIDs to populate Principal.user.{id,personId}.
const provisionerOrder = provisioner.ensureUser.mock.invocationCallOrder[0] ?? Infinity;
const buildOrder = principalBuilder.build.mock.invocationCallOrder[0] ?? 0;
expect(provisionerOrder).toBeLessThan(buildOrder);
const sess = (req as unknown as { session: Record<string, unknown> }).session;
const principal = sess['principal'] as Principal;
expect(principal).toBeDefined();
expect(principal.user.entraOid).toBe(USER.oid);
expect(principal.user.id).toBe(PROVISIONED_IDENTITY.userId);
expect(principal.user.personId).toBe(PROVISIONED_IDENTITY.personId);
expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]);
expect(principal.mfaVerifiedAt).toBe(sess['createdAt']);
});
it('propagates provisioner failures (blocking per ADR-0026)', async () => {
const { est, provisioner, audit, directory, principalBuilder } = makeFixture();
provisioner.ensureUser.mockRejectedValueOnce(new Error('postgres unreachable'));
const req = makeReqStub();
const res = makeResStub();
await expect(est.establish({ user: USER, req, res, surface: 'user' })).rejects.toThrow(
'postgres unreachable',
);
// Nothing downstream of the provisioner runs when it fails.
expect(principalBuilder.build).not.toHaveBeenCalled();
expect(audit.signIn).not.toHaveBeenCalled();
expect(directory.recordSignIn).not.toHaveBeenCalled();
});
it('saves the session before returning (no race with the controller redirect)', async () => {
const { est } = makeFixture();
const req = makeReqStub();
@@ -6,10 +6,8 @@ import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
import { readSessionTimeouts } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
import { UserDirectoryService } from '../users/user-directory.service';
import type { AuthenticatedUser } from './auth.service';
import { PrincipalBuilder } from './principal-builder';
export type AuthSurface = 'user' | 'admin';
@@ -49,8 +47,6 @@ export class SessionEstablisher {
private readonly userSessionIndex: UserSessionIndexService,
private readonly audit: AuditWriter,
private readonly userDirectory: UserDirectoryService,
private readonly personUserProvisioner: PersonAndUserProvisioner,
private readonly principalBuilder: PrincipalBuilder,
) {}
async establish(opts: {
@@ -83,28 +79,6 @@ export class SessionEstablisher {
// does not re-validate factors. Refreshed by future step-up
// re-auth flows.
req.session.mfaVerifiedAt = now;
// Provision Person + User (blocking per ADR-0026 §"Lifecycle"):
// the BFF cannot build a Principal without User.id / Person.id,
// so a Postgres outage here fails the sign-in. Idempotent —
// returns the existing pair's UUIDs on subsequent sign-ins.
// Note: the Entra `preferred_username` claim maps to Person.email
// (it's typically the user's UPN / email); `AuthenticatedUser.username`
// is the BFF's name for the same field.
const identity = await this.personUserProvisioner.ensureUser({
oid: user.oid,
tenantId: user.tid,
displayName: user.displayName,
email: user.username,
});
// Authorization principal per ADR-0025: composes the three
// axes (privileges / functional roles / scopes) once at
// sign-in so guards read a single coherent shape instead of
// re-parsing claims on every request. Built before the
// session is persisted so a Redis hiccup either persists
// everything or nothing.
const principal = await this.principalBuilder.build(user, identity);
req.session.principal = { ...principal, mfaVerifiedAt: now };
await saveSession(req);
@@ -1,90 +0,0 @@
import { describe, it, expect, beforeEach, afterAll } from '@jest/globals';
import { assertAiServiceConfig } from './check-ai-service-config';
/**
* Pre-flight validation contract for `AI_SERVICE_*` env vars.
* Mirrors the test posture of `check-log-user-id-salt.spec.ts`
* each rejection path produces an actionable error message.
*/
const ORIGINAL_ENV = { ...process.env };
function setEnv(values: Record<string, string | undefined>): void {
for (const [key, value] of Object.entries(values)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
beforeEach(() => {
delete process.env['AI_SERVICE_GRPC_ENDPOINT'];
delete process.env['AI_SERVICE_CLIENT_ID'];
delete process.env['AI_SERVICE_GRPC_TLS'];
});
afterAll(() => {
process.env = { ...ORIGINAL_ENV };
});
describe('assertAiServiceConfig', () => {
it('returns the parsed config when every required var is present', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
AI_SERVICE_GRPC_TLS: 'false',
});
expect(assertAiServiceConfig()).toEqual({
endpoint: 'apf-ai-service:8080',
clientId: 'apf-portal-dev',
useTls: false,
});
});
it('defaults useTls to true when AI_SERVICE_GRPC_TLS is unset', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai.internal:443',
AI_SERVICE_CLIENT_ID: 'apf-portal-prod',
});
expect(assertAiServiceConfig().useTls).toBe(true);
});
it('throws when AI_SERVICE_GRPC_ENDPOINT is missing', () => {
setEnv({ AI_SERVICE_CLIENT_ID: 'apf-portal-dev' });
expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_GRPC_ENDPOINT is not set/);
});
it('throws when AI_SERVICE_GRPC_ENDPOINT does not match host:port', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'no-port-here',
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
});
expect(() => assertAiServiceConfig()).toThrow(/must match "host:port"/);
});
it('throws when AI_SERVICE_CLIENT_ID is missing', () => {
setEnv({ AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080' });
expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_CLIENT_ID is not set/);
});
it('throws when AI_SERVICE_CLIENT_ID has invalid characters', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
AI_SERVICE_CLIENT_ID: 'Apf Portal Dev',
});
expect(() => assertAiServiceConfig()).toThrow(/lowercase kebab-case slug/);
});
it('throws when AI_SERVICE_GRPC_TLS is neither "true" nor "false"', () => {
setEnv({
AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080',
AI_SERVICE_CLIENT_ID: 'apf-portal-dev',
AI_SERVICE_GRPC_TLS: 'yes',
});
expect(() => assertAiServiceConfig()).toThrow(/must be "true" or "false"/);
});
});
@@ -1,79 +0,0 @@
/**
* Validate the env vars that drive the BFF's gRPC client to
* `apf-ai-service`, per ADR-0024.
*
* Three knobs, all required at module-init time of `AiClientModule`:
*
* - `AI_SERVICE_GRPC_ENDPOINT` `host:port` reachable from the
* BFF. `apf-ai-service:8080` in the dev Compose network;
* a service DNS name + 443 in prod behind an edge proxy that
* terminates the public TLS and forwards h2 internally.
* - `AI_SERVICE_CLIENT_ID` deployment slug propagated as the
* `x-client-id` gRPC metadata on every call (per
* `apf-ai-service/docs/contract.md`). Naming convention:
* `apf-portal-<env>` (`apf-portal-dev`, `apf-portal-preprod`,
* `apf-portal-prod`).
* - `AI_SERVICE_GRPC_TLS` `'true'` to dial with TLS credentials
* against a CA-signed cert (default for non-dev), `'false'` for
* h2c against the Compose network in dev. Any other value is
* rejected to keep the cleartext-vs-TLS toggle explicit.
*
* Following the same family as `assertLogUserIdSalt()` and the
* other config validators under `apps/portal-bff/src/config/`,
* per ADR-0018 §"BFF env-var loading" small, per-key, run at
* bootstrap, throw with an actionable message on misconfiguration.
*/
export interface AiServiceConfig {
readonly endpoint: string;
readonly clientId: string;
readonly useTls: boolean;
}
const ENDPOINT_PATTERN = /^[a-z0-9.-]+:[0-9]{2,5}$/i;
const CLIENT_ID_PATTERN = /^[a-z0-9-]+$/;
export function assertAiServiceConfig(): AiServiceConfig {
const endpoint = process.env['AI_SERVICE_GRPC_ENDPOINT'];
if (!endpoint || endpoint === '') {
throw new Error(
`AI_SERVICE_GRPC_ENDPOINT is not set. Expected "host:port" reachable from the BFF, ` +
`e.g. "apf-ai-service:8080" in dev Compose or "apf-ai.internal:443" in prod.`,
);
}
if (!ENDPOINT_PATTERN.test(endpoint)) {
throw new Error(
`AI_SERVICE_GRPC_ENDPOINT must match "host:port" (got: ${JSON.stringify(endpoint)}). ` +
`Hostname uses a-z 0-9 . - characters; port is 2-5 digits.`,
);
}
const clientId = process.env['AI_SERVICE_CLIENT_ID'];
if (!clientId || clientId === '') {
throw new Error(
`AI_SERVICE_CLIENT_ID is not set. Use a deployment slug like ` +
`"apf-portal-dev" / "apf-portal-preprod" / "apf-portal-prod"; ` +
`propagated as the x-client-id gRPC metadata on every call.`,
);
}
if (!CLIENT_ID_PATTERN.test(clientId)) {
throw new Error(
`AI_SERVICE_CLIENT_ID must be a lowercase kebab-case slug ` +
`(got: ${JSON.stringify(clientId)}). Pattern: ^[a-z0-9-]+$.`,
);
}
const tlsRaw = process.env['AI_SERVICE_GRPC_TLS'] ?? 'true';
if (tlsRaw !== 'true' && tlsRaw !== 'false') {
throw new Error(
`AI_SERVICE_GRPC_TLS must be "true" or "false" (got: ${JSON.stringify(tlsRaw)}). ` +
`Default in prod is "true"; the dev Compose stack sets it to "false" for h2c.`,
);
}
return {
endpoint,
clientId,
useTls: tlsRaw === 'true',
};
}
@@ -1,93 +0,0 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EntraGroupMapError } from 'shared-auth';
import { loadEntraGroupToRoleResolver } from './load-entra-group-map';
function tmpFile(name: string, contents: string): string {
const dir = mkdtempSync(join(tmpdir(), 'entra-map-'));
const path = join(dir, name);
writeFileSync(path, contents, 'utf8');
return path;
}
function noopWarn(): { onWarn: jest.Mock } {
return { onWarn: jest.fn() };
}
describe('loadEntraGroupToRoleResolver', () => {
it('returns an empty resolver and warns when ENTRA_GROUP_MAP_PATH is unset', () => {
const { onWarn } = noopWarn();
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
env: {},
onWarn,
});
expect(resolver.size).toBe(0);
expect(sourcePath).toBeNull();
expect(onWarn).toHaveBeenCalledWith('auth.entra_group_map_path_unset', expect.any(Object));
});
it('returns an empty resolver and warns when the file cannot be read', () => {
const { onWarn } = noopWarn();
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: '/tmp/definitely-not-a-real-path-xyz.json' },
onWarn,
});
expect(resolver.size).toBe(0);
expect(sourcePath).toBe('/tmp/definitely-not-a-real-path-xyz.json');
expect(onWarn).toHaveBeenCalledWith(
'auth.entra_group_map_file_unreadable',
expect.objectContaining({ path: '/tmp/definitely-not-a-real-path-xyz.json' }),
);
});
it('throws on malformed JSON', () => {
const path = tmpFile('bad.json', '{ not json');
expect(() =>
loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
}),
).toThrow(EntraGroupMapError);
});
it('throws when the JSON is not an object', () => {
const path = tmpFile('arr.json', '["collaborateur"]');
expect(() =>
loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
}),
).toThrow(EntraGroupMapError);
});
it('throws when a value is not a string', () => {
const path = tmpFile(
'bad-val.json',
JSON.stringify({ '11111111-1111-1111-1111-111111111111': 42 }),
);
expect(() =>
loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
}),
).toThrow(EntraGroupMapError);
});
it('returns a populated resolver on a valid map', () => {
const path = tmpFile(
'good.json',
JSON.stringify({
'11111111-1111-1111-1111-111111111111': 'collaborateur',
'22222222-2222-2222-2222-222222222222': 'rh',
}),
);
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
env: { ENTRA_GROUP_MAP_PATH: path },
onWarn: jest.fn(),
});
expect(resolver.size).toBe(2);
expect(sourcePath).toBe(path);
expect(resolver.coveredRoles()).toEqual(['collaborateur', 'rh']);
});
});
@@ -1,98 +0,0 @@
import { readFileSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { EntraGroupMapError, EntraGroupToRoleResolver, parseEntraGroupMap } from 'shared-auth';
/**
* Outcome of `loadEntraGroupToRoleResolver` paired with the
* source path (when set) so the boot log can name the file that
* was loaded, even when the resolver itself ends up empty.
*/
export interface EntraGroupResolverLoadResult {
readonly resolver: EntraGroupToRoleResolver;
/** `null` when no path was configured (env var unset). */
readonly sourcePath: string | null;
}
/**
* Loads the Entra group-GUID role-slug map at BFF boot per
* [ADR-0025 §"Sources of truth — Entra-side configuration"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* Sourcing precedence:
* 1. `ENTRA_GROUP_MAP_PATH` env var JSON file path (absolute,
* or relative to `cwd`).
* 2. Unset empty resolver, WARN logged at boot. Sign-in still
* works but every user gets an empty `roles[]` until the
* operator wires up the file. Acceptable for fresh dev
* environments; production should always set the var.
*
* Failure modes:
* - File configured but unreadable / missing empty resolver,
* WARN. Same fail-soft posture: an operator pointing at the
* wrong path should not block every sign-in.
* - File present but malformed (bad JSON, wrong shape, unknown
* slug, duplicate GUID) throws. A misconfigured map is a
* hard-fail because the alternative is silently mis-resolving
* a role.
*
* The function never reads `process.env` outside the documented
* key and never mutates it.
*/
export function loadEntraGroupToRoleResolver(opts: {
cwd?: string;
env?: NodeJS.ProcessEnv;
onWarn: (event: string, payload: Record<string, unknown>) => void;
}): EntraGroupResolverLoadResult {
const env = opts.env ?? process.env;
const cwd = opts.cwd ?? process.cwd();
const rawPath = env['ENTRA_GROUP_MAP_PATH'];
if (typeof rawPath !== 'string' || rawPath === '') {
opts.onWarn('auth.entra_group_map_path_unset', {
hint: 'set ENTRA_GROUP_MAP_PATH to infra/<env>-tenant.entra.json — sign-in will succeed but resolve zero functional roles until configured',
});
return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: null };
}
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
let raw: string;
try {
raw = readFileSync(absolutePath, 'utf8');
} catch (err) {
opts.onWarn('auth.entra_group_map_file_unreadable', {
path: absolutePath,
message: err instanceof Error ? err.message : String(err),
});
return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: absolutePath };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new EntraGroupMapError(
`Entra group map at ${absolutePath} is not valid JSON: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new EntraGroupMapError(
`Entra group map at ${absolutePath} must be a JSON object keyed on group GUID.`,
);
}
const stringified: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value !== 'string') {
throw new EntraGroupMapError(
`Entra group map entry "${key}" in ${absolutePath} must be a string (the role slug); got ${typeof value}.`,
);
}
stringified[key] = value;
}
const map = parseEntraGroupMap(stringified);
return { resolver: new EntraGroupToRoleResolver(map), sourcePath: absolutePath };
}
@@ -1,397 +0,0 @@
import { EventEmitter } from 'node:events';
import { randomBytes } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import { UnauthorizedException } from '@nestjs/common';
import {
ChannelCredentials,
Server,
ServerCredentials,
status as GrpcStatus,
type sendUnaryData,
type ServerUnaryCall,
type ServerWritableStream,
} from '@grpc/grpc-js';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import { ChatClient } from '../ai-client/chat.client';
import { GrpcMetadataBuilder } from '../ai-client/grpc-metadata.builder';
import { ModelsClient } from '../ai-client/models.client';
import { PrincipalMapper } from '../ai-client/principal.mapper';
import { RagClient } from '../ai-client/rag.client';
import {
ChatServiceClient,
ChatServiceService,
type ChatEvent,
type ChatRequest,
} from '../gen/apf-ai/chat';
import {
ModelsServiceClient,
ModelsServiceService,
type ListModelsRequest,
type ListModelsResponse,
} from '../gen/apf-ai/models';
import {
RagServiceClient,
RagServiceService,
type RagSearchRequest,
type RagSearchResponse,
} from '../gen/apf-ai/rag';
import { Struct } from '../gen/apf-ai/google/protobuf/struct';
import { AiBridgeController } from './ai-bridge.controller';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
import type { AuthenticatedUser } from '../../auth/auth.service';
/**
* Exercises `AiBridgeController` end-to-end through the gRPC wire
* against an in-process fake of every service the controller calls.
*
* The controller is a thin translation layer (DTO proto, gRPC
* SSE, session Principal). The spec confirms each direction of
* the translation lands the right bytes.
*/
const STRONG_SALT = randomBytes(32).toString('base64url');
const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT'];
beforeAll(() => {
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
});
afterAll(() => {
if (ORIGINAL_SALT === undefined) {
delete process.env['LOG_USER_ID_SALT'];
} else {
process.env['LOG_USER_ID_SALT'] = ORIGINAL_SALT;
}
});
const USER: AuthenticatedUser = {
oid: 'user-oid-42',
tid: 'tenant-1',
username: 'alice@example.org',
displayName: 'Alice',
amr: ['pwd', 'mfa'],
roles: ['admin'],
groups: [],
};
// ---- Fake gRPC server (Chat + Rag + Models) -------------------
let server: Server;
let port: number;
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
let ragHandler: (
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
callback: sendUnaryData<RagSearchResponse>,
) => void;
let modelsHandler: (
call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
callback: sendUnaryData<ListModelsResponse>,
) => void;
let observedChatRequest: ChatRequest | null = null;
beforeAll(async () => {
server = new Server();
server.addService(ChatServiceService, {
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
observedChatRequest = call.request;
chatHandler(call);
},
});
server.addService(RagServiceService, {
search: (
call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
callback: sendUnaryData<RagSearchResponse>,
) => ragHandler(call, callback),
});
server.addService(ModelsServiceService, {
listModels: (
call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
callback: sendUnaryData<ListModelsResponse>,
) => modelsHandler(call, callback),
});
port = await new Promise<number>((resolve, reject) => {
server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
if (err) {
reject(err);
return;
}
resolve(bound);
});
});
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.tryShutdown((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
// ---- Controller wiring --------------------------------------
function buildController(): AiBridgeController {
const config: AiServiceConfig = {
endpoint: `127.0.0.1:${port}`,
clientId: 'apf-portal-test',
useTls: false,
};
const credentials = ChannelCredentials.createInsecure();
const metadata = new GrpcMetadataBuilder(config);
const chatClient = new ChatClient(new ChatServiceClient(config.endpoint, credentials), metadata);
const ragClient = new RagClient(new RagServiceClient(config.endpoint, credentials), metadata);
const modelsClient = new ModelsClient(
new ModelsServiceClient(config.endpoint, credentials),
metadata,
);
const principalMapper = new PrincipalMapper(new HashUserIdService());
return new AiBridgeController(chatClient, ragClient, modelsClient, principalMapper);
}
// ---- Mock Express Request / Response -----------------------
interface CapturedResponse {
statusCode: number | undefined;
headers: Record<string, string>;
body: string;
ended: boolean;
writableEnded: boolean;
}
function mockReq(user: AuthenticatedUser | undefined): {
req: import('express').Request;
close: () => void;
} {
const emitter = new EventEmitter();
const req = Object.assign(emitter, {
session: { user },
}) as unknown as import('express').Request;
return {
req,
close: () => emitter.emit('close'),
};
}
function mockRes(): { res: import('express').Response; captured: CapturedResponse } {
const captured: CapturedResponse = {
statusCode: undefined,
headers: {},
body: '',
ended: false,
writableEnded: false,
};
const res = {
set statusCode(value: number) {
captured.statusCode = value;
},
get writableEnded() {
return captured.writableEnded;
},
setHeader(name: string, value: string): void {
captured.headers[name] = value;
},
flushHeaders(): void {
// no-op for the mock
},
write(chunk: string | Buffer): boolean {
captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
},
end(chunk?: string | Buffer): void {
if (chunk !== undefined) {
captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
}
captured.ended = true;
captured.writableEnded = true;
},
};
return { res: res as unknown as import('express').Response, captured };
}
beforeEach(() => {
observedChatRequest = null;
});
// ---------------------------------------------------------------
describe('AiBridgeController — POST /api/ai/chat (SSE bridge)', () => {
it('streams ChatEvent frames as SSE and sets the right headers', async () => {
chatHandler = (call) => {
call.write({ token: { token: 'hi', value: 'Hi' } });
call.write({ token: { token: ' there', value: ' there' } });
call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
call.end();
};
const controller = buildController();
const { req } = mockReq(USER);
const { res, captured } = mockRes();
await controller.chat(
{
messages: [{ role: 'user', content: 'hello' }],
conversationId: 'c-1',
},
req,
res,
);
expect(captured.headers['Content-Type']).toBe('text/event-stream; charset=utf-8');
expect(captured.headers['Cache-Control']).toBe('no-cache, no-transform');
expect(captured.headers['X-Accel-Buffering']).toBe('no');
expect(captured.body).toContain('event: token\n');
expect(captured.body).toContain('"value":"Hi"');
expect(captured.body).toContain('event: done\n');
expect(captured.ended).toBe(true);
});
it('places a hashed subject and pass-through roles in the proto Principal', async () => {
chatHandler = (call) => {
call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
call.end();
};
const controller = buildController();
const { req } = mockReq(USER);
const { res } = mockRes();
await controller.chat(
{
messages: [{ role: 'user', content: 'q' }],
},
req,
res,
);
expect(observedChatRequest?.principal?.subject).toMatch(/^[0-9a-f]{16}$/);
expect(observedChatRequest?.principal?.subject).not.toBe(USER.oid);
expect(observedChatRequest?.principal?.roles).toEqual(['admin']);
expect(observedChatRequest?.principal?.attributes).toEqual({ tenantId: 'tenant-1' });
expect(observedChatRequest?.toolsAvailable).toEqual([]);
});
it('rejects with 401 when no session.user is present', async () => {
const controller = buildController();
const { req } = mockReq(undefined);
const { res } = mockRes();
await expect(
controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('cancels the gRPC call when the request emits "close" mid-stream', async () => {
const cancellationObserved = new Promise<void>((resolve) => {
chatHandler = (call) => {
call.write({ token: { token: 't', value: 't' } });
call.on('cancelled', () => resolve());
// never end — wait for client cancellation
};
});
const controller = buildController();
const { req, close } = mockReq(USER);
const { res } = mockRes();
const completion = controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);
// Give the server time to emit the first frame then cut the
// request — this is the controller's documented browser-close
// pathway.
setTimeout(close, 30);
await completion;
await cancellationObserved;
});
it('writes a relay error frame on upstream failure', async () => {
chatHandler = (call) => {
call.emit('error', {
code: GrpcStatus.UNAVAILABLE,
details: 'upstream down',
message: 'upstream down',
});
};
const controller = buildController();
const { req } = mockReq(USER);
const { res, captured } = mockRes();
await controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);
expect(captured.body).toContain('event: error\n');
expect(captured.body).toContain('"code":"urn:apf-ai:unavailable"');
expect(captured.body).toContain('"retriable":true');
expect(captured.ended).toBe(true);
});
});
describe('AiBridgeController — GET /api/ai/rag/search', () => {
it('returns the unary response', async () => {
ragHandler = (_call, callback) => {
callback(null, {
chunks: [
{
id: 'k',
documentId: 'd',
content: 'snippet',
source: 'src',
score: 1,
metadata: Struct.fromPartial({}),
},
],
correlationId: 'corr-1',
});
};
const controller = buildController();
const { req } = mockReq(USER);
const response = await controller.ragSearch({ query: 'hello', topK: 3 }, req);
expect(response.chunks).toHaveLength(1);
expect(response.correlationId).toBe('corr-1');
});
it('rejects with 401 when no session.user', async () => {
const controller = buildController();
const { req } = mockReq(undefined);
await expect(controller.ragSearch({ query: 'x' }, req)).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
});
describe('AiBridgeController — GET /api/ai/models', () => {
it('returns the model list', async () => {
modelsHandler = (_call, callback) => {
callback(null, {
active: 'openai-compatible',
providers: [
{
discriminator: 'openai-compatible',
capabilities: 'chat,embedding',
endpoint: 'http://ollama:11434/v1',
model: 'qwen2.5:3b',
embeddingModel: 'nomic-embed-text',
},
],
});
};
const controller = buildController();
const { req } = mockReq(USER);
const response = await controller.listModels(req);
expect(response.active).toBe('openai-compatible');
expect(response.providers).toHaveLength(1);
});
it('rejects with 401 when no session.user', async () => {
const controller = buildController();
const { req } = mockReq(undefined);
await expect(controller.listModels(req)).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -1,232 +0,0 @@
import {
Body,
Controller,
Get,
HttpStatus,
Post,
Query,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { status as GrpcStatus, type ServiceError } from '@grpc/grpc-js';
import type { Request, Response } from 'express';
import { ChatClient } from '../ai-client/chat.client';
import { ModelsClient } from '../ai-client/models.client';
import { PrincipalMapper } from '../ai-client/principal.mapper';
import { RagClient } from '../ai-client/rag.client';
import type { ChatEvent, ChatRequest } from '../gen/apf-ai/chat';
import { ChatRole } from '../gen/apf-ai/common';
import type { ListModelsResponse } from '../gen/apf-ai/models';
import type { RagSearchResponse } from '../gen/apf-ai/rag';
import { ChatRequestDto, type ChatMessageDto } from './dto/chat-request.dto';
import { RagSearchQueryDto } from './dto/rag-search-query.dto';
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
import type { AuthenticatedUser } from '../../auth/auth.service';
/**
* BFF-facing surface of the AI relay, per
* [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md):
*
* - `POST /api/ai/chat` streaming chat, bridged from
* `ChatService.Chat` (gRPC server-
* stream) to `text/event-stream`.
* - `GET /api/ai/rag/search` unary RAG retrieval.
* - `GET /api/ai/models` list configured providers.
*
* No `@RequireAdmin()` AI features are end-user surfaces, gated
* only by the active portal session (`req.session.user`). The
* session + CSRF middleware mounted in `main.ts` covers the
* authentication + double-submit token before the controller
* runs; this class asserts presence of `session.user` for the
* routes that need it and lets the global middleware handle the
* rest.
*/
@ApiTags('ai')
@ApiCookieAuth('portal_session')
@Controller('ai')
export class AiBridgeController {
constructor(
private readonly chatClient: ChatClient,
private readonly ragClient: RagClient,
private readonly modelsClient: ModelsClient,
private readonly principalMapper: PrincipalMapper,
) {}
@ApiOperation({
summary:
'Streaming chat. Returns text/event-stream with one frame per ChatEvent (token / citation / agent-step / tool-call / error / done).',
})
@Post('chat')
async chat(
@Body() body: ChatRequestDto,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const user = requireSessionUser(req);
const principal = this.principalMapper.fromInputs({
oid: user.oid,
tid: user.tid,
roles: user.roles,
});
const protoRequest: ChatRequest = {
messages: body.messages.map(toProtoMessage),
conversationId: body.conversationId ?? '',
model: body.model ?? '',
provider: body.provider ?? '',
toolsAvailable: [],
// v1: tool registry is empty (per ADR-0024 §"Tool-dispatch
// contract"). The AI service will never emit `tool_call` for
// requests with `toolsAvailable: []`; the SSE writer still
// covers the case in case a future tool lands.
rag: { enabled: false, topK: 0 },
principal,
};
writeSseHeaders(res);
// Browser disconnect (tab close, fetch abort, network drop) →
// AbortController → gRPC call.cancel() → upstream LLM stop.
// Per ADR-0024 §"SSE bridge between BFF and SPA".
const abort = new AbortController();
req.on('close', () => {
if (!res.writableEnded) {
abort.abort();
}
});
const stream = this.chatClient.chat(protoRequest, { signal: abort.signal });
try {
for await (const event of stream as AsyncIterable<ChatEvent>) {
const frame = chatEventToSseFrame(event);
if (frame !== null) {
res.write(frame);
}
}
} catch (err) {
// gRPC `CANCELLED` after `abort.abort()` is the expected exit
// path on browser disconnect — do not surface an error frame
// because the response is already closing. Anything else
// becomes a structured error event so the SPA's renderer
// sees the failure rather than a torn-down connection.
const code = (err as Partial<ServiceError>).code;
const cancelled = code === GrpcStatus.CANCELLED;
if (!cancelled && !res.writableEnded) {
res.write(
relayErrorFrame(
mapServiceErrorCode(code),
(err as Error).message ?? 'AI relay error',
isRetriable(code),
),
);
}
} finally {
if (!res.writableEnded) {
res.end();
}
}
}
@ApiOperation({ summary: 'Unary RAG retrieval bounded by the caller principal.' })
@Get('rag/search')
async ragSearch(
@Query() query: RagSearchQueryDto,
@Req() req: Request,
): Promise<RagSearchResponse> {
const user = requireSessionUser(req);
const principal = this.principalMapper.fromInputs({
oid: user.oid,
tid: user.tid,
roles: user.roles,
});
return this.ragClient.search({
query: query.query,
topK: query.topK ?? 0,
filters: {
source: query.source ?? '',
documentId: query.documentId ?? '',
},
principal,
});
}
@ApiOperation({ summary: 'List configured AI providers and the active provider.' })
@Get('models')
async listModels(@Req() req: Request): Promise<ListModelsResponse> {
requireSessionUser(req);
return this.modelsClient.listModels({});
}
}
function requireSessionUser(req: Request): AuthenticatedUser {
const user = req.session.user;
if (!user) {
throw new UnauthorizedException({
code: 'unauthenticated',
message: 'The AI surface requires an authenticated portal session.',
});
}
return user;
}
function writeSseHeaders(res: Response): void {
res.statusCode = HttpStatus.OK;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
// nginx-style buffering hint — keeps reverse-proxies from
// accumulating chunks before forwarding, which would defeat the
// streaming UX.
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
}
function toProtoMessage(message: ChatMessageDto): {
role: ChatRole;
content: string;
toolCallId: string;
name: string;
} {
return {
role: ROLE_PROTO[message.role],
content: message.content,
toolCallId: '',
name: '',
};
}
const ROLE_PROTO: Record<ChatMessageDto['role'], ChatRole> = {
system: ChatRole.CHAT_ROLE_SYSTEM,
user: ChatRole.CHAT_ROLE_USER,
assistant: ChatRole.CHAT_ROLE_ASSISTANT,
};
/**
* gRPC status SSE error code. Keeps the urn:apf-ai:* namespace
* the AI service itself uses so a relay-side error and an upstream
* error look the same to the SPA's renderer.
*/
function mapServiceErrorCode(code: number | undefined): string {
switch (code) {
case GrpcStatus.UNAVAILABLE:
return 'urn:apf-ai:unavailable';
case GrpcStatus.DEADLINE_EXCEEDED:
return 'urn:apf-ai:timeout';
case GrpcStatus.PERMISSION_DENIED:
return 'urn:apf-ai:permission_denied';
case GrpcStatus.RESOURCE_EXHAUSTED:
return 'urn:apf-ai:rate_limited';
case GrpcStatus.INVALID_ARGUMENT:
return 'urn:apf-ai:invalid_argument';
default:
return 'urn:apf-ai:relay_error';
}
}
function isRetriable(code: number | undefined): boolean {
return code === GrpcStatus.UNAVAILABLE || code === GrpcStatus.DEADLINE_EXCEEDED;
}
@@ -1,24 +0,0 @@
import { Module } from '@nestjs/common';
import { AiClientModule } from '../ai-client/ai-client.module';
import { AiBridgeController } from './ai-bridge.controller';
/**
* Hosts `AiBridgeController` and depends on `AiClientModule` for
* the four wrapper clients (`ChatClient`, `RagClient`,
* `ModelsClient`, plus `PrincipalMapper`). `AiClientModule`'s
* `OnApplicationShutdown` lifecycle closes the gRPC stubs at
* process termination wiring this module into `AppModule`
* brings both the controller AND the shutdown contract along.
*
* Per [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)
* §"Out of scope for this ADR" the ingestion surface is not
* exposed via the BFF in v1 `IngestionClient` is in
* `AiClientModule` for future reuse but `AiBridgeController`
* does not surface it. The CLI under `apf-ai-service/tools/Apf.Ai.Ingest/`
* is the v1 ingestion path.
*/
@Module({
imports: [AiClientModule],
controllers: [AiBridgeController],
})
export class AiBridgeModule {}
@@ -1,77 +0,0 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
/**
* Roles the SPA may attach to a message in the chat history.
*
* `tool` is intentionally absent tool-result messages are
* constructed by the BFF itself (caller-side tool dispatch, per
* [ADR-0024](../../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md)
* §"Tool-dispatch contract — caller-side execution") and never
* provided by the SPA. v1 ships with an empty tool registry, so
* no `tool` message ever reaches this controller.
*/
const ALLOWED_ROLES = ['user', 'assistant', 'system'] as const;
type AllowedRole = (typeof ALLOWED_ROLES)[number];
export class ChatMessageDto {
@IsIn(ALLOWED_ROLES)
role!: AllowedRole;
/**
* Free-form message body. The cap is generous (16 KB) to support
* code blocks and pasted excerpts; the BFF leaves further
* pre-processing (truncation, token counting) to the AI service.
*/
@IsString()
@MinLength(0)
@MaxLength(16_384)
content!: string;
}
/**
* POST /api/ai/chat body.
*
* Stateless from the BFF's perspective the SPA owns the
* conversation history. `conversationId` is an opaque correlation
* key used by the AI service's audit log (not a database key on
* either side); the SPA may omit it for one-off calls.
*/
export class ChatRequestDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(64)
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
@IsOptional()
@IsString()
@MaxLength(128)
conversationId?: string;
/**
* Optional model + provider hints. Both default to "" on the
* wire, which the AI service interprets as "use the configured
* default". The SPA does not need to pass these in v1.
*/
@IsOptional()
@IsString()
@MaxLength(64)
model?: string;
@IsOptional()
@IsString()
@MaxLength(64)
provider?: string;
}
@@ -1,31 +0,0 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min, MinLength } from 'class-validator';
/**
* Query-string DTO for `GET /api/ai/rag/search`.
*
* Bounded the upstream RAG service has its own server-side cap on
* `top_k`, but the BFF rejects obviously-out-of-range values early
* so a single SPA bug cannot induce repeated 400s round-tripped
* through the AI service.
*/
export class RagSearchQueryDto {
@IsString()
@MinLength(1)
query!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
topK?: number;
@IsOptional()
@IsString()
source?: string;
@IsOptional()
@IsString()
documentId?: string;
}
@@ -1,81 +0,0 @@
import { describe, it, expect } from '@jest/globals';
import { chatEventToSseFrame, relayErrorFrame } from './sse.writer';
/**
* Locks the wire shape the SPA consumes. Each frame is:
*
* event: <name>\n
* data: <json>\n
* \n
*
* The terminal blank line is the SSE message separator.
*/
describe('chatEventToSseFrame', () => {
it('maps token events with the JSON-encoded inner value', () => {
const frame = chatEventToSseFrame({ token: { token: 't1', value: 'hello' } });
expect(frame).toBe(`event: token\ndata: {"token":"t1","value":"hello"}\n\n`);
});
it('maps citation events', () => {
const frame = chatEventToSseFrame({
citation: {
chunkId: 'c-1',
documentId: 'd-1',
source: 's',
score: 0.42,
snippet: 'snip',
},
});
expect(frame?.startsWith('event: citation\n')).toBe(true);
expect(frame).toContain('"chunkId":"c-1"');
});
it('maps agent_step to kebab-case `agent-step`', () => {
const frame = chatEventToSseFrame({
agentStep: { agent: 'a', step: 's', stepId: '1' },
});
expect(frame?.startsWith('event: agent-step\n')).toBe(true);
});
it('maps tool_call to kebab-case `tool-call`', () => {
const frame = chatEventToSseFrame({
toolCall: { callId: 'c-1', name: 'echo', args: undefined },
});
expect(frame?.startsWith('event: tool-call\n')).toBe(true);
});
it('maps error events', () => {
const frame = chatEventToSseFrame({
error: { code: 'urn:apf-ai:foo', message: 'bad', retriable: false },
});
expect(frame).toBe(
`event: error\ndata: {"code":"urn:apf-ai:foo","message":"bad","retriable":false}\n\n`,
);
});
it('maps done as the terminal frame', () => {
const frame = chatEventToSseFrame({
done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } },
});
expect(frame?.startsWith('event: done\n')).toBe(true);
expect(frame?.endsWith('\n\n')).toBe(true);
});
it('returns null when no oneof case is populated', () => {
expect(chatEventToSseFrame({})).toBeNull();
});
});
describe('relayErrorFrame', () => {
it('emits an error frame with the supplied code + message + retriable flag', () => {
expect(relayErrorFrame('urn:apf-ai:relay_error', 'kaboom', true)).toBe(
`event: error\ndata: {"code":"urn:apf-ai:relay_error","message":"kaboom","retriable":true}\n\n`,
);
});
it('defaults `retriable` to false when omitted', () => {
const frame = relayErrorFrame('urn:apf-ai:foo', 'msg');
expect(frame).toContain('"retriable":false');
});
});
@@ -1,70 +0,0 @@
import type { ChatEvent } from '../gen/apf-ai/chat';
/**
* Translate one `apf.ai.v1.ChatEvent` into a single SSE frame for
* the SPA, per ADR-0024 §"Sub-decision 2 SSE bridge between BFF
* and SPA". The mapping is intentionally one-to-one with the
* proto `oneof` cases:
*
* token `event: token` / data = TokenEvent JSON
* citation `event: citation` / data = CitationEvent JSON
* agent_step `event: agent-step` / data = AgentStepEvent JSON
* tool_call `event: tool-call` / data = ToolCallEvent JSON
* error `event: error` / data = ErrorEvent JSON
* done `event: done` / data = DoneEvent JSON
*
* SSE event names use kebab-case so the SPA's `EventSource` /
* fetch-streaming consumer can dispatch with `.addEventListener('agent-step', …)`
* without re-mapping proto camelCase. The terminal `done` frame is
* the contract's stream-close marker no `[DONE]` sentinel, per
* ADR-0024.
*
* Returns `null` when the event carries no populated oneof case
* (defensive gRPC-js will not produce this in practice, but the
* caller can safely skip on `null` rather than emit an empty
* frame). The data payload is JSON.stringified; consumers parse
* with `JSON.parse(event.data)`.
*/
export function chatEventToSseFrame(event: ChatEvent): string | null {
if (event.token !== undefined) {
return formatFrame('token', event.token);
}
if (event.citation !== undefined) {
return formatFrame('citation', event.citation);
}
if (event.agentStep !== undefined) {
return formatFrame('agent-step', event.agentStep);
}
if (event.toolCall !== undefined) {
return formatFrame('tool-call', event.toolCall);
}
if (event.error !== undefined) {
return formatFrame('error', event.error);
}
if (event.done !== undefined) {
return formatFrame('done', event.done);
}
return null;
}
/**
* Convenience used by the controller's error path: synthesise a
* `urn:apf-ai:relay_error` event frame so the SPA receives a
* structured failure rather than a torn-down connection. Matches the
* shape of the AI service's own `ErrorEvent` so the SPA's renderer
* does not need a second code path for relay-level failures vs
* upstream model errors.
*/
export function relayErrorFrame(code: string, message: string, retriable = false): string {
return formatFrame('error', { code, message, retriable });
}
function formatFrame(eventName: string, data: unknown): string {
// SSE spec: every field line ends with `\n`, the frame is
// terminated by a blank line (`\n\n`). `data:` is followed by a
// single space convention. `JSON.stringify` produces no newlines
// for plain objects, so a single `data:` line is correct; a
// multi-line payload (not used by this codec) would require
// splitting on `\n` and prefixing each part with `data:`.
return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
}
@@ -1,109 +0,0 @@
import { randomBytes } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { AiClientModule } from './ai-client.module';
import { ChatClient } from './chat.client';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { IngestionClient } from './ingestion.client';
import { ModelsClient } from './models.client';
import { PrincipalMapper } from './principal.mapper';
import { RagClient } from './rag.client';
import {
AI_CHAT_GRPC_CLIENT,
AI_CONFIG,
AI_CREDENTIALS,
AI_INGESTION_GRPC_CLIENT,
AI_MODELS_GRPC_CLIENT,
AI_RAG_GRPC_CLIENT,
} from './tokens';
/**
* Smoke-tests `AiClientModule.compile()` every provider resolves,
* the four exported wrapper clients are constructable, and the gRPC
* stubs share the same credentials instance (i.e. the underlying
* HTTP/2 channel can be multiplexed by grpc-js).
*
* Does NOT exercise the wire the wire is covered by the per-client
* fake-server specs.
*/
const STRONG_SALT = randomBytes(32).toString('base64url');
const ORIGINAL = {
salt: process.env['LOG_USER_ID_SALT'],
endpoint: process.env['AI_SERVICE_GRPC_ENDPOINT'],
clientId: process.env['AI_SERVICE_CLIENT_ID'],
tls: process.env['AI_SERVICE_GRPC_TLS'],
};
beforeAll(() => {
process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
process.env['AI_SERVICE_GRPC_ENDPOINT'] = 'apf-ai-service:8080';
process.env['AI_SERVICE_CLIENT_ID'] = 'apf-portal-test';
process.env['AI_SERVICE_GRPC_TLS'] = 'false';
});
afterAll(() => {
if (ORIGINAL.salt === undefined) delete process.env['LOG_USER_ID_SALT'];
else process.env['LOG_USER_ID_SALT'] = ORIGINAL.salt;
if (ORIGINAL.endpoint === undefined) delete process.env['AI_SERVICE_GRPC_ENDPOINT'];
else process.env['AI_SERVICE_GRPC_ENDPOINT'] = ORIGINAL.endpoint;
if (ORIGINAL.clientId === undefined) delete process.env['AI_SERVICE_CLIENT_ID'];
else process.env['AI_SERVICE_CLIENT_ID'] = ORIGINAL.clientId;
if (ORIGINAL.tls === undefined) delete process.env['AI_SERVICE_GRPC_TLS'];
else process.env['AI_SERVICE_GRPC_TLS'] = ORIGINAL.tls;
});
describe('AiClientModule', () => {
let moduleRef: Awaited<ReturnType<ReturnType<typeof Test.createTestingModule>['compile']>>;
beforeEach(async () => {
// AiClientModule provides HashUserIdService locally — see the
// module docstring for the rationale — so the test does not
// need to bring in AuditModule (and AuditWriter's Prisma + CLS
// dependencies that would imply).
moduleRef = await Test.createTestingModule({
imports: [AiClientModule],
}).compile();
});
it('resolves all four wrapper clients', () => {
expect(moduleRef.get(ChatClient)).toBeInstanceOf(ChatClient);
expect(moduleRef.get(RagClient)).toBeInstanceOf(RagClient);
expect(moduleRef.get(IngestionClient)).toBeInstanceOf(IngestionClient);
expect(moduleRef.get(ModelsClient)).toBeInstanceOf(ModelsClient);
});
it('resolves the Principal mapper and the metadata builder', () => {
expect(moduleRef.get(PrincipalMapper)).toBeInstanceOf(PrincipalMapper);
expect(moduleRef.get(GrpcMetadataBuilder)).toBeInstanceOf(GrpcMetadataBuilder);
});
it('parses the env vars into AI_CONFIG', () => {
const config = moduleRef.get(AI_CONFIG) as {
endpoint: string;
clientId: string;
useTls: boolean;
};
expect(config).toEqual({
endpoint: 'apf-ai-service:8080',
clientId: 'apf-portal-test',
useTls: false,
});
});
it('shares one credentials instance across every generated stub', () => {
const credentials = moduleRef.get(AI_CREDENTIALS);
expect(credentials).toBeDefined();
// Each stub provider injects AI_CREDENTIALS — same reference
// means the underlying HTTP/2 connection can be multiplexed by
// grpc-js when they target the same address.
const chat = moduleRef.get(AI_CHAT_GRPC_CLIENT) as unknown;
const rag = moduleRef.get(AI_RAG_GRPC_CLIENT) as unknown;
const ingestion = moduleRef.get(AI_INGESTION_GRPC_CLIENT) as unknown;
const models = moduleRef.get(AI_MODELS_GRPC_CLIENT) as unknown;
expect(chat).toBeDefined();
expect(rag).toBeDefined();
expect(ingestion).toBeDefined();
expect(models).toBeDefined();
});
});
@@ -1,153 +0,0 @@
import { Inject, Module, type OnApplicationShutdown, type Provider } from '@nestjs/common';
import { ChannelCredentials, type Client } from '@grpc/grpc-js';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import { assertAiServiceConfig, type AiServiceConfig } from '../../config/check-ai-service-config';
import { ChatServiceClient } from '../gen/apf-ai/chat';
import { IngestionServiceClient } from '../gen/apf-ai/ingestion';
import { ModelsServiceClient } from '../gen/apf-ai/models';
import { RagServiceClient } from '../gen/apf-ai/rag';
import { ChatClient } from './chat.client';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { IngestionClient } from './ingestion.client';
import { ModelsClient } from './models.client';
import { PrincipalMapper } from './principal.mapper';
import { RagClient } from './rag.client';
import {
AI_CHAT_GRPC_CLIENT,
AI_CONFIG,
AI_CREDENTIALS,
AI_INGESTION_GRPC_CLIENT,
AI_MODELS_GRPC_CLIENT,
AI_RAG_GRPC_CLIENT,
} from './tokens';
/**
* Wires the BFF's integration surface to `apf-ai-service`, per
* [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md).
*
* The module is intentionally NOT imported in `AppModule` yet the
* SSE-bridge controller that consumes these clients ships in the
* next chantier. Until then the module compiles, type-checks, and
* unit-tests against an in-process fake gRPC server, but it does not
* register any HTTP route.
*
* Provider layout:
*
* 1. `AI_CONFIG` factory reads + validates `AI_SERVICE_*` env vars.
* 2. `AI_CREDENTIALS` factory builds the gRPC channel credentials
* (insecure h2c in dev, system-CA-trusted SSL in prod).
* 3. One token per generated stub `AI_CHAT_GRPC_CLIENT` etc.
* backed by a factory that opens a `Client` against the
* configured endpoint. gRPC-js multiplexes the underlying
* HTTP/2 connection across stubs sharing the same address +
* credentials, so the four stubs share one TCP+TLS channel.
* 4. Hand-written wrapper services (`ChatClient`, `RagClient`,
* `IngestionClient`, `ModelsClient`) add metadata injection
* and promisification on top of the generated stubs.
* 5. `PrincipalMapper` + `GrpcMetadataBuilder` collaborators.
*
* `PrincipalMapper` depends on `HashUserIdService` the same hash
* service the audit writer uses so the `Principal.subject` field
* matches `audit.events.actor_id_hash` exactly (per ADR-0024
* §"Audit cross-referencing"). `HashUserIdService` is declared as a
* local provider here (rather than relying on `AuditModule`'s
* `@Global()` export) for two reasons:
*
* 1. **Test isolation.** The module compiles standalone with no
* other portal module in scope; no need to pull `AuditWriter`
* and its Prisma + CLS deps into a unit test that only exercises
* the gRPC surface.
* 2. **Self-containment of the wire boundary.** The Principal-side
* contract is a property of `AiClientModule`, not of the audit
* module. Co-locating the hash provider keeps the boundary
* readable from one file.
*
* Functional identity with the audit-side instance is guaranteed by
* `HashUserIdService` being purely a function of
* `LOG_USER_ID_SALT` same env value same hash output, regardless
* of how many instances exist. The cross-service join key invariant
* from ADR-0013 still holds.
*/
function buildCredentials(config: AiServiceConfig): ChannelCredentials {
return config.useTls ? ChannelCredentials.createSsl() : ChannelCredentials.createInsecure();
}
const grpcStubProviders: Provider[] = [
{
provide: AI_CHAT_GRPC_CLIENT,
inject: [AI_CONFIG, AI_CREDENTIALS],
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
new ChatServiceClient(config.endpoint, credentials),
},
{
provide: AI_RAG_GRPC_CLIENT,
inject: [AI_CONFIG, AI_CREDENTIALS],
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
new RagServiceClient(config.endpoint, credentials),
},
{
provide: AI_INGESTION_GRPC_CLIENT,
inject: [AI_CONFIG, AI_CREDENTIALS],
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
new IngestionServiceClient(config.endpoint, credentials),
},
{
provide: AI_MODELS_GRPC_CLIENT,
inject: [AI_CONFIG, AI_CREDENTIALS],
useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) =>
new ModelsServiceClient(config.endpoint, credentials),
},
];
@Module({
providers: [
{
provide: AI_CONFIG,
useFactory: () => assertAiServiceConfig(),
},
{
provide: AI_CREDENTIALS,
inject: [AI_CONFIG],
useFactory: buildCredentials,
},
...grpcStubProviders,
HashUserIdService,
GrpcMetadataBuilder,
PrincipalMapper,
ChatClient,
RagClient,
IngestionClient,
ModelsClient,
],
exports: [ChatClient, RagClient, IngestionClient, ModelsClient, PrincipalMapper],
})
export class AiClientModule implements OnApplicationShutdown {
constructor(
@Inject(AI_CHAT_GRPC_CLIENT) private readonly chatStub: Client,
@Inject(AI_RAG_GRPC_CLIENT) private readonly ragStub: Client,
@Inject(AI_INGESTION_GRPC_CLIENT) private readonly ingestionStub: Client,
@Inject(AI_MODELS_GRPC_CLIENT) private readonly modelsStub: Client,
) {}
/**
* Close every generated gRPC stub when the BFF receives `SIGTERM`
* / `SIGINT`. Each `Client.close()` flushes pending RPCs (with
* their own gRPC `CANCELLED` semantics) and tears down the
* shared HTTP/2 channel so the process can exit promptly without
* waiting for the channel's keepalive PINGs.
*
* The four stubs share the same underlying HTTP/2 channel (same
* endpoint + same credentials, gRPC-js de-duplicates), so the
* four `close()` calls are cheap but kept explicit adding a
* fifth stub later means adding a fifth `close()` line, which is
* easier to spot than iterating an array that grew without
* review.
*/
onApplicationShutdown(): void {
this.chatStub.close();
this.ragStub.close();
this.ingestionStub.close();
this.modelsStub.close();
}
}
@@ -1,236 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import {
ChannelCredentials,
Server,
ServerCredentials,
type Metadata,
type ServerWritableStream,
} from '@grpc/grpc-js';
import {
ChatServiceClient,
ChatServiceService,
type ChatEvent,
type ChatRequest,
} from '../gen/apf-ai/chat';
import { ChatClient } from './chat.client';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Integration test: drives `ChatClient` against an in-process fake
* gRPC ChatService that emits a canned `ChatEvent` sequence. The
* spec covers ADR-0024's confirmation criteria:
*
* - happy-path stream emits every event the server sent and ends
* after `done`;
* - metadata (`x-client-id` + `x-correlation-id`) reaches the
* server unchanged;
* - browser-close (modelled here as an `AbortController.abort()`)
* propagates as `call.cancel()` and the server's stream callback
* observes the cancellation.
*/
const CONFIG: AiServiceConfig = {
endpoint: '',
clientId: 'apf-portal-test',
useTls: false,
};
interface ServerObservations {
request: ChatRequest | null;
metadata: Metadata | null;
cancelled: boolean;
}
let server: Server;
let port: number;
let serverObservations: ServerObservations;
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
beforeAll(async () => {
server = new Server();
server.addService(ChatServiceService, {
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
serverObservations.request = call.request;
serverObservations.metadata = call.metadata;
call.on('cancelled', () => {
serverObservations.cancelled = true;
});
chatHandler(call);
},
});
port = await new Promise<number>((resolve, reject) => {
server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
if (err) {
reject(err);
return;
}
resolve(bound);
});
});
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.tryShutdown((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
beforeEach(() => {
serverObservations = { request: null, metadata: null, cancelled: false };
});
function buildClient(): ChatClient {
const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` };
const grpc = new ChatServiceClient(config.endpoint, ChannelCredentials.createInsecure());
return new ChatClient(grpc, new GrpcMetadataBuilder(config));
}
async function collect(stream: AsyncIterable<ChatEvent>): Promise<ChatEvent[]> {
const events: ChatEvent[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
describe('ChatClient (in-process fake server)', () => {
it('emits every server event then terminates on done', async () => {
chatHandler = (call) => {
call.write({ token: { token: 'hello', value: 'Hello' } });
call.write({ token: { token: ' world', value: ' world' } });
call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
call.end();
};
const client = buildClient();
const stream = client.chat({
messages: [],
conversationId: 'conv-1',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-1', roles: ['admin'], attributes: { tenantId: 't' } },
});
const events = await collect(stream);
expect(events).toHaveLength(3);
expect(events[0]?.token?.value).toBe('Hello');
expect(events[2]?.done?.stats?.tokensOut).toBe(2);
});
it('propagates x-client-id and x-correlation-id metadata to the server', async () => {
chatHandler = (call) => {
call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
call.end();
};
const client = buildClient();
await collect(
client.chat(
{
messages: [],
conversationId: 'conv-2',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-2', roles: [], attributes: {} },
},
{ correlationId: 'corr-from-test' },
),
);
expect(serverObservations.metadata?.get('x-client-id')).toEqual(['apf-portal-test']);
expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['corr-from-test']);
});
it('cancels the call when the AbortSignal aborts mid-stream', async () => {
const cancellationObserved = new Promise<void>((resolve) => {
chatHandler = (call) => {
call.write({ token: { token: 't1', value: 't1' } });
call.on('cancelled', () => resolve());
// Deliberately do not `end()` — the test cancels.
};
});
const controller = new AbortController();
const client = buildClient();
const stream = client.chat(
{
messages: [],
conversationId: 'conv-3',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-3', roles: [], attributes: {} },
},
{ signal: controller.signal },
);
// Drain the first event then abort.
const iter = stream[Symbol.asyncIterator]();
await iter.next();
controller.abort();
await cancellationObserved;
expect(serverObservations.cancelled).toBe(true);
});
it('terminates the stream without data when the AbortSignal is already aborted', async () => {
// The signal aborts before the call dial completes. gRPC-js
// cancels locally — depending on timing the server may or may
// not observe the call, so the spec only locks the client-side
// outcome: the stream ends in error and yields no payload.
chatHandler = (call) => {
call.on('cancelled', () => {
// best-effort end on cancellation so the suite never hangs
try {
call.end();
} catch {
// already torn down
}
});
};
const controller = new AbortController();
controller.abort();
const client = buildClient();
const stream = client.chat(
{
messages: [],
conversationId: 'conv-4',
model: '',
provider: '',
toolsAvailable: [],
principal: { subject: 'subj-4', roles: [], attributes: {} },
},
{ signal: controller.signal },
);
const events: ChatEvent[] = [];
let caught: unknown;
try {
for await (const ev of stream) {
events.push(ev);
}
} catch (err) {
caught = err;
}
expect(events).toHaveLength(0);
// gRPC-js may surface the cancellation as an error or as a clean
// end-of-stream depending on whether the call had time to dial.
// Either way, no payload is emitted — that is the contract.
if (caught !== undefined) {
expect((caught as { code?: number }).code).toBeDefined();
}
});
});
@@ -1,69 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ClientReadableStream } from '@grpc/grpc-js';
import type { ChatEvent, ChatRequest, ChatServiceClient } from '../gen/apf-ai/chat';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_CHAT_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `ChatServiceClient` for the
* server-streaming `Chat` RPC, per ADR-0024 §"Sub-decision 1".
*
* The wrapper does three things on top of the raw gRPC stub:
*
* 1. Injects the `x-client-id` + `x-correlation-id` metadata via
* `GrpcMetadataBuilder` so every call carries the contract
* headers from `apf-ai-service/docs/contract.md`.
* 2. Wires an optional `AbortSignal` to `call.cancel()` so the SSE
* bridge (next PR in the chantier) can propagate browser
* disconnects up to the AI service in one line.
* 3. Returns the raw `ClientReadableStream<ChatEvent>` Node
* `Readable` streams are async-iterable by default, so the SSE
* bridge consumes the stream with `for await ... of` and no
* intermediate adapter.
*
* Cancellation flows in both directions: a `.cancel()` from inside
* the BFF (timeout, abort) reaches the AI service as a
* `grpc.status.CANCELLED`, which translates upstream to
* `ServerCallContext.CancellationToken` stopping LLM generation
* (per `apf-ai-service/docs/streaming.md`).
*/
@Injectable()
export class ChatClient {
constructor(
@Inject(AI_CHAT_GRPC_CLIENT) private readonly grpc: ChatServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
/**
* Start a streaming Chat call.
*
* The returned `ClientReadableStream<ChatEvent>` emits one event
* per AI service `ChatEvent` and ends after the terminal
* `ChatEvent.done` (or on cancellation / error).
*
* @param request The full proto request, already populated with
* the conversation history and a `Principal` (build the
* principal via `PrincipalMapper.fromInputs`).
* @param options.signal Optional AbortSignal. When it aborts, the
* underlying gRPC call is cancelled and the stream closes.
* @param options.correlationId Override the metadata
* correlation-id (defaults to the active OTel trace-id).
*/
chat(
request: ChatRequest,
options: { signal?: AbortSignal; correlationId?: string } = {},
): ClientReadableStream<ChatEvent> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
const call = this.grpc.chat(request, metadata);
if (options.signal) {
if (options.signal.aborted) {
call.cancel();
} else {
options.signal.addEventListener('abort', () => call.cancel(), { once: true });
}
}
return call;
}
}
@@ -1,87 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { trace } from '@opentelemetry/api';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Locks the metadata-injection contract per ADR-0024 §"Metadata contract":
*
* - x-client-id AI_SERVICE_CLIENT_ID env (via AI_CONFIG).
* - x-correlation-id active OTel span's trace-id when present;
* explicit `correlationId` override wins over
* the span; otherwise a fresh UUID is minted
* so the AI service can still join its audit
* row to a unique id even when the portal
* request is outside a trace.
*/
const CONFIG: AiServiceConfig = {
endpoint: 'apf-ai-service:8080',
clientId: 'apf-portal-test',
useTls: false,
};
let activeSpan: ReturnType<typeof trace.getActiveSpan> = undefined;
let getActiveSpanSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
activeSpan = undefined;
getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockImplementation(() => activeSpan);
});
afterEach(() => {
getActiveSpanSpy.mockRestore();
});
describe('GrpcMetadataBuilder', () => {
it('always emits x-client-id from the config', () => {
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build();
expect(meta.get('x-client-id')).toEqual(['apf-portal-test']);
});
it('uses the active OTel trace-id as x-correlation-id when a span is active', () => {
activeSpan = {
spanContext: () => ({
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
spanId: 'bbbbbbbbbbbbbbbb',
traceFlags: 1,
}),
} as ReturnType<typeof trace.getActiveSpan>;
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build();
expect(meta.get('x-correlation-id')).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']);
});
it('prefers the explicit options.correlationId over the active span', () => {
activeSpan = {
spanContext: () => ({
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
spanId: 'bbbbbbbbbbbbbbbb',
traceFlags: 1,
}),
} as ReturnType<typeof trace.getActiveSpan>;
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build({ correlationId: 'caller-supplied-id' });
expect(meta.get('x-correlation-id')).toEqual(['caller-supplied-id']);
});
it('falls back to a UUID v4 when no span is active and no override is passed', () => {
const builder = new GrpcMetadataBuilder(CONFIG);
const meta = builder.build();
const correlationIds = meta.get('x-correlation-id') as string[];
expect(correlationIds).toHaveLength(1);
expect(correlationIds[0]).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('returns a fresh Metadata instance on every call (no shared mutation)', () => {
const builder = new GrpcMetadataBuilder(CONFIG);
const a = builder.build();
const b = builder.build();
expect(a).not.toBe(b);
});
});
@@ -1,53 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { Metadata } from '@grpc/grpc-js';
import { trace } from '@opentelemetry/api';
import { AI_CONFIG } from './tokens';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
/**
* Builds the gRPC `Metadata` object every outbound call to
* `apf-ai-service` must carry, per ADR-0024 §"Metadata contract".
*
* Two entries:
*
* - `x-client-id` deployment slug from `AI_SERVICE_CLIENT_ID`.
* Lets the AI service tag its audit rows and metrics with the
* calling deployment without inspecting the proto body.
* - `x-correlation-id` W3C `trace-id` of the active OpenTelemetry
* span when one exists (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)),
* falling back to an explicit value if passed, falling back to a
* freshly-minted UUID v4 as a last resort. The AI service echoes
* this id in its audit log and propagates it to Jaeger, so the
* portal and AI traces can be joined cross-service.
*
* The builder is a class (not a static helper) so it composes with
* NestJS DI and so it can be swapped for a fake in tests that need
* to assert the metadata shape without standing up the OTel SDK.
*/
@Injectable()
export class GrpcMetadataBuilder {
constructor(@Inject(AI_CONFIG) private readonly config: AiServiceConfig) {}
/**
* Construct the metadata for one outbound call.
*
* @param options.correlationId Override the auto-resolved value.
* Use when the caller already holds a stable id (for example
* the SSE bridge controller's request-scoped trace-id) and
* wants to bind multiple calls to the same correlation key.
* `undefined` is accepted (and equivalent to omitting the
* key) so callers can forward an optional value without
* a conditional spread.
*/
build(options: { correlationId?: string | undefined } = {}): Metadata {
const meta = new Metadata();
meta.set('x-client-id', this.config.clientId);
const correlationId =
options.correlationId ?? trace.getActiveSpan()?.spanContext().traceId ?? randomUUID();
meta.set('x-correlation-id', correlationId);
return meta;
}
}
@@ -1,78 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
DeleteDocumentRequest,
DeleteDocumentResponse,
DocumentSummary,
GetDocumentRequest,
IngestDocumentRequest,
IngestDocumentResponse,
IngestionServiceClient,
} from '../gen/apf-ai/ingestion';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_INGESTION_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `IngestionServiceClient`.
*
* Unused by the BFF in v1 apf-ai-service ships a dedicated CLI
* (`tools/Apf.Ai.Ingest/`) for document ingestion during the POC.
* The wrapper is in place so the future admin "manage AI corpus"
* surface lands as one new controller, not as a new module + a new
* client + a new generated stub binding. The unit tests under
* `ingestion.client.spec.ts` lock the wire contract today.
*/
@Injectable()
export class IngestionClient {
constructor(
@Inject(AI_INGESTION_GRPC_CLIENT) private readonly grpc: IngestionServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
ingestDocument(
request: IngestDocumentRequest,
options: { correlationId?: string } = {},
): Promise<IngestDocumentResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<IngestDocumentResponse>((resolve, reject) => {
this.grpc.ingestDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
getDocument(
request: GetDocumentRequest,
options: { correlationId?: string } = {},
): Promise<DocumentSummary> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<DocumentSummary>((resolve, reject) => {
this.grpc.getDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
deleteDocument(
request: DeleteDocumentRequest,
options: { correlationId?: string } = {},
): Promise<DeleteDocumentResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<DeleteDocumentResponse>((resolve, reject) => {
this.grpc.deleteDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}
@@ -1,38 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
ListModelsRequest,
ListModelsResponse,
ModelsServiceClient,
} from '../gen/apf-ai/models';
import { GrpcMetadataBuilder } from './grpc-metadata.builder';
import { AI_MODELS_GRPC_CLIENT } from './tokens';
/**
* Wrapper around the generated `ModelsServiceClient` for the unary
* `ListModels` RPC. Surfaces the active provider + the configured
* provider catalogue for use by a future admin UI tile or a v1
* "which models are available" health probe.
*/
@Injectable()
export class ModelsClient {
constructor(
@Inject(AI_MODELS_GRPC_CLIENT) private readonly grpc: ModelsServiceClient,
private readonly metadata: GrpcMetadataBuilder,
) {}
listModels(
request: ListModelsRequest = {},
options: { correlationId?: string } = {},
): Promise<ListModelsResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<ListModelsResponse>((resolve, reject) => {
this.grpc.listModels(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}

Some files were not shown because too many files have changed in this diff Show More