Compare commits
2 Commits
main
..
1a20490320
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a20490320 | |||
| f776ce732a |
@@ -1,14 +0,0 @@
|
|||||||
# Deque axe Linter config — silences false positives the static
|
|
||||||
# HTML scanner emits against Angular 17+ control-flow blocks
|
|
||||||
# (`@for`, `@if`, `@switch`) used inline in templates.
|
|
||||||
#
|
|
||||||
# The `list` rule (WCAG 1.3.1) flags `@for (item of …) { <li>… }`
|
|
||||||
# constructs as if the `@for` text were a non-<li> child of <ul>/<ol>.
|
|
||||||
# At build time, the Angular compiler erases those tokens — the
|
|
||||||
# rendered DOM only contains <li> children. The axe-playwright suite
|
|
||||||
# wired into CI per ADR-0016 runs against the rendered DOM, so the
|
|
||||||
# real accessibility contract is enforced there; this file only
|
|
||||||
# tames the editor-side noise.
|
|
||||||
|
|
||||||
global-disable:
|
|
||||||
- list
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -44,15 +44,6 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm ci:check
|
- 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:
|
scan:
|
||||||
runs-on: [self-hosted, on-prem]
|
runs-on: [self-hosted, on-prem]
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# Per ADR-0022 (Documentation site — VitePress).
|
|
||||||
# Builds the static docs site from `docs/`. On `pull_request` the
|
|
||||||
# build is a syntax / dead-link gate; on `push` to `main` the same
|
|
||||||
# build runs and the bundle is uploaded as an artifact so the
|
|
||||||
# operator can drop it on the static host until the future
|
|
||||||
# infrastructure ADR locks the rsync target.
|
|
||||||
#
|
|
||||||
# Path filter scopes the workflow to changes that can actually
|
|
||||||
# affect the rendered site: documentation content, the VitePress
|
|
||||||
# configuration itself, and dependency manifests (Vite/VitePress
|
|
||||||
# upgrades).
|
|
||||||
|
|
||||||
name: Docs site
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- '.gitea/workflows/docs-site.yml'
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- '.gitea/workflows/docs-site.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, on-prem]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version-file: '.nvmrc'
|
|
||||||
cache: 'pnpm'
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
# `pnpm docs:build` runs `vitepress build docs` per the script
|
|
||||||
# added in this chantier. Fails the workflow on:
|
|
||||||
# - markdown / Vue-template parse errors,
|
|
||||||
# - genuine dead in-site links (the config's
|
|
||||||
# `ignoreDeadLinks` regex already covers the intentional
|
|
||||||
# cross-repo + localhost references that must not break
|
|
||||||
# the build).
|
|
||||||
- name: Build docs site
|
|
||||||
run: pnpm docs:build
|
|
||||||
# Sanity-check: the build must produce HTML where the Mermaid
|
|
||||||
# plugin has injected its diagram markers. Guards against a
|
|
||||||
# silent regression on the plugin upgrade path — `pnpm install`
|
|
||||||
# might happily resolve a new major that no longer hooks into
|
|
||||||
# markdown-it the same way, leaving fenced ```mermaid blocks
|
|
||||||
# as raw text. Per ADR-0022 §"Confirmation".
|
|
||||||
- name: Assert Mermaid renders into the build output
|
|
||||||
run: |
|
|
||||||
if ! grep -RIlq 'class="mermaid"\|<svg' docs/.vitepress/dist/decisions/0009-auth-flow-oidc-pkce-msal-node.html; then
|
|
||||||
echo "ADR-0009's Mermaid sequence diagram did not render — Mermaid plugin regression?" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# On push only — upload the static bundle so the operator can
|
|
||||||
# rsync it to the host until the future infra ADR formalises
|
|
||||||
# the deployment target. The artifact lives 30 days in Gitea's
|
|
||||||
# storage by default.
|
|
||||||
- name: Upload docs bundle
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: docs-site
|
|
||||||
path: docs/.vitepress/dist/
|
|
||||||
retention-days: 30
|
|
||||||
-13
@@ -10,10 +10,6 @@ tmp/
|
|||||||
.nx/cache/
|
.nx/cache/
|
||||||
.nx/workspace-data/
|
.nx/workspace-data/
|
||||||
|
|
||||||
# VitePress (docs site per ADR-0022)
|
|
||||||
docs/.vitepress/cache/
|
|
||||||
docs/.vitepress/dist/
|
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
coverage/
|
coverage/
|
||||||
.test-output/
|
.test-output/
|
||||||
@@ -31,15 +27,6 @@ pnpm-debug.log*
|
|||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.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
|
# OS / editor scrap
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
-159
@@ -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,8 +5,3 @@
|
|||||||
/.nx/workspace-data
|
/.nx/workspace-data
|
||||||
.nx/self-healing
|
.nx/self-healing
|
||||||
.angular
|
.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/
|
|
||||||
|
|||||||
@@ -43,43 +43,29 @@ 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).
|
- **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).
|
- **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).
|
- **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).
|
- **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).
|
- **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).
|
- **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).
|
||||||
- **Internationalisation:** `@angular/localize` in build-time mode, two locales (`fr` default served at `/`, `en`), source locale = English (project English-only rule). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. UI strings live in XLIFF (`messages.fr.xlf`); editorial / CMS content is BFF-served already localised (see admin app). Footer hosts the locale switcher; switching writes a `__Host-portal_locale` cookie and hard-refreshes — see [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md).
|
- **Internationalisation:** `@angular/localize` in build-time mode, two locales (`fr` default served at `/`, `en`), source locale = English (project English-only rule). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. UI strings live in XLIFF (`messages.fr.xlf`); editorial / CMS content is BFF-served already localised (see admin app). Footer hosts the locale switcher; switching writes a `__Host-portal_locale` cookie and hard-refreshes — see [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md).
|
||||||
- **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).
|
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `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).
|
- **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 2–3-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.
|
- **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
|
## 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 → 0021 are accepted and cover the structural, security, observability, quality, i18n, and admin-app 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-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-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.
|
- **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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
**Still on the roadmap:**
|
**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.
|
- `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()` 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.
|
- `@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.
|
||||||
- **`@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.
|
|
||||||
- **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.
|
- **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
|
## Commands once the workspace exists
|
||||||
@@ -109,10 +95,8 @@ pnpm nx format:check
|
|||||||
|
|
||||||
## Environment conventions
|
## 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.
|
- **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.
|
- **pnpm is mandatory** (activated via `corepack enable`); do not introduce npm or yarn lockfiles.
|
||||||
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
|
- Prettier config target: `singleQuote: true`, `semi: true`, `printWidth: 100`.
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "6kb",
|
"maximumWarning": "5kb",
|
||||||
"maximumError": "8kb"
|
"maximumError": "6kb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "bundle",
|
"type": "bundle",
|
||||||
@@ -84,8 +84,7 @@
|
|||||||
"continuous": true,
|
"continuous": true,
|
||||||
"executor": "@angular/build:dev-server",
|
"executor": "@angular/build:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"port": 4300,
|
"port": 4300
|
||||||
"proxyConfig": "apps/portal-admin/proxy.conf.js"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -93,12 +92,6 @@
|
|||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "portal-admin:build: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"
|
"defaultConfiguration": "development"
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravitdesign.com --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1024 1024" width="1024pt" height="1024pt"><defs><clipPath id="_clipPath_B4TZ570FigxU4c50hle9QrZwbv6YvFBZ"><rect width="1024" height="1024"/></clipPath></defs><g clip-path="url(#_clipPath_B4TZ570FigxU4c50hle9QrZwbv6YvFBZ)"><path d=" M 302.7 566.118 C 355.817 515.422 427.036 483.173 501.691 480.62 C 626.026 477.14 721.654 557.185 738.018 669.829 C 734.571 656.604 628.618 409.16 302.701 566.118 L 302.7 566.118 Z " fill="rgb(255, 255, 255)"/><g><path d=" M 732.849 505.91 C 675.421 450.11 499.114 228.071 514.048 161.831 C 530.416 89.558 636.371 30.51 735.43 0 C 719.64 2.552 706.724 5.104 694.661 8.585 C 586.122 37.355 472.984 99.302 454.893 175.868 C 439.96 241.297 498.247 315.31 562.572 378.65 C 584.25 400.069 569.598 402.126 540.415 406.219 C 515.859 409.664 481.014 414.554 448.867 433.637 C 527.084 402.036 655.737 470.669 711.434 500.382 C 735.478 513.208 745.922 518.782 732.849 505.91 L 732.849 505.91 Z " fill="rgb(255, 255, 255)"/><path d=" M 711.885 266.354 C 737.15 226.331 785.688 206.494 821.289 222.039 C 856.897 237.816 865.799 283.06 841.394 323.894 C 816.989 363.917 768.451 384.102 732.85 368.325 C 697.242 352.78 687.774 307.538 711.885 266.354 Z " fill="rgb(255, 255, 255)"/><path d=" M 307.282 582.591 C 617.41 441.409 727.668 675.86 732.836 700.109 C 735.416 720.173 735.416 742.1 731.969 763.679 C 711.878 907.414 578.07 1024 433.052 1024 C 288.045 1024 187.249 907.414 207.353 763.679 C 217.694 693.146 255.022 629.459 307.282 582.591 L 307.282 582.591 Z M 450.565 937.11 C 546.769 937.11 636.357 859.614 649.278 763.679 C 663.344 668.089 595.583 590.364 499.101 590.364 C 467.005 590.364 435.702 598.966 407.73 613.965 C 388.702 624.169 371.215 637.334 356.069 652.766 C 326.553 682.839 305.925 721.522 300.1 763.679 C 286.323 859.614 354.084 937.11 450.566 937.11 L 450.565 937.11 Z " fill-rule="evenodd" fill="rgb(255, 255, 255)"/></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,49 +1,7 @@
|
|||||||
import {
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
ApplicationConfig,
|
|
||||||
provideBrowserGlobalErrorListeners,
|
|
||||||
provideZonelessChangeDetection,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import {
|
|
||||||
AUTH_BFF_BASE_URL,
|
|
||||||
AUTH_CSRF_COOKIE_NAME,
|
|
||||||
AUTH_PATH_PREFIX,
|
|
||||||
bffCredentialsInterceptor,
|
|
||||||
bffUnauthorizedInterceptor,
|
|
||||||
csrfInterceptor,
|
|
||||||
} from 'feature-auth';
|
|
||||||
import { environment } from '../environments/environment';
|
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
|
||||||
provideZonelessChangeDetection(),
|
|
||||||
provideBrowserGlobalErrorListeners(),
|
|
||||||
provideRouter(appRoutes),
|
|
||||||
// `withFetch()` makes Angular's HttpClient delegate to the
|
|
||||||
// browser `fetch` API — what
|
|
||||||
// `@opentelemetry/instrumentation-fetch` patches, so every
|
|
||||||
// HttpClient request gets its own span and W3C `traceparent`
|
|
||||||
// header automatically.
|
|
||||||
//
|
|
||||||
// Same interceptor chain as portal-shell (per ADR-0009 / ADR-0021):
|
|
||||||
// - bffCredentialsInterceptor: withCredentials on BFF URLs.
|
|
||||||
// - bffUnauthorizedInterceptor: refresh AuthService on 401.
|
|
||||||
// - csrfInterceptor: echo CSRF cookie on mutating requests.
|
|
||||||
provideHttpClient(
|
|
||||||
withFetch(),
|
|
||||||
withInterceptors([bffCredentialsInterceptor, csrfInterceptor, bffUnauthorizedInterceptor]),
|
|
||||||
),
|
|
||||||
// `feature-auth` is environment-agnostic. portal-admin diverges
|
|
||||||
// from portal-shell on a single token: AUTH_PATH_PREFIX. The
|
|
||||||
// BFF mounts the admin OIDC routes under `/api/admin/auth/*`
|
|
||||||
// (per ADR-0020 §"Sessions — distinct from `portal-shell`"), so
|
|
||||||
// the AuthService composes `${bffApiBaseUrl}/admin/auth/{me,login,logout}`.
|
|
||||||
// The session middleware on the BFF resolves `/api/admin/*` to
|
|
||||||
// the distinct `__Host-portal_admin_session` cookie.
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: environment.bffApiBaseUrl },
|
|
||||||
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
|
||||||
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: environment.bffCsrfCookieName },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
|
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
|
||||||
<app-admin-header />
|
<main id="main-content" tabindex="-1" class="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div class="shell-body">
|
<router-outlet />
|
||||||
<app-admin-sidebar />
|
</main>
|
||||||
<main id="main-content" tabindex="-1" class="shell-main">
|
|
||||||
<router-outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<app-admin-footer />
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
import { authGuard } from 'feature-auth';
|
|
||||||
|
|
||||||
const homeTitle = $localize`:@@route.home.title:APF Portal Admin`;
|
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[] = [
|
export const appRoutes: Route[] = [
|
||||||
{
|
{
|
||||||
@@ -14,27 +9,6 @@ export const appRoutes: Route[] = [
|
|||||||
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
|
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
|
||||||
title: homeTitle,
|
title: homeTitle,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'audit',
|
|
||||||
loadComponent: () => import('./pages/audit/audit').then((m) => m.AuditPage),
|
|
||||||
title: auditTitle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'users',
|
|
||||||
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],
|
|
||||||
loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage),
|
|
||||||
title: profileTitle,
|
|
||||||
},
|
|
||||||
// Catch-all — unknown paths bounce to home. Same role as the
|
// Catch-all — unknown paths bounce to home. Same role as the
|
||||||
// wildcard in portal-shell (per PR #96): in production each locale
|
// wildcard in portal-shell (per PR #96): in production each locale
|
||||||
// bundle ships with its own `<base href="/{locale}/">` and the
|
// bundle ships with its own `<base href="/{locale}/">` and the
|
||||||
|
|||||||
@@ -1,35 +1,3 @@
|
|||||||
// Admin shell: full-viewport flex column — header on top, sidebar +
|
|
||||||
// main side-by-side filling the middle, footer pinned at the bottom.
|
|
||||||
// The sidebar owns its own scrolling so a long nav doesn't push the
|
|
||||||
// header off-screen; main scrolls independently so audit-log tables
|
|
||||||
// don't drag the chrome with them.
|
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell-main {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
color: #111827;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .shell-main {
|
|
||||||
background-color: #0b1220;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
|
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
|
||||||
// visible and focusable when reached via Tab from the address bar.
|
// visible and focusable when reached via Tab from the address bar.
|
||||||
// Plain CSS rather than the `sr-only` Tailwind utilities so the
|
// Plain CSS rather than the `sr-only` Tailwind utilities so the
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from 'feature-auth';
|
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [
|
providers: [provideRouter([])],
|
||||||
provideRouter([]),
|
|
||||||
provideHttpClient(),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: 'http://bff.test/api' },
|
|
||||||
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: 'portal_csrf' },
|
|
||||||
{ provide: AUTH_NAVIGATOR, useValue: vi.fn() },
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the admin shell — skip link, header, sidebar, main landmark, footer', async () => {
|
it('renders the layout shell — skip link, main landmark', async () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('a.skip-link')).not.toBeNull();
|
expect(root.querySelector('a.skip-link')).not.toBeNull();
|
||||||
expect(root.querySelector('main#main-content')).not.toBeNull();
|
expect(root.querySelector('main#main-content')).not.toBeNull();
|
||||||
expect(root.querySelector('app-admin-header')).not.toBeNull();
|
|
||||||
expect(root.querySelector('app-admin-sidebar')).not.toBeNull();
|
|
||||||
expect(root.querySelector('app-admin-footer')).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { AdminFooter } from './components/footer/footer';
|
|
||||||
import { AdminHeader } from './components/header/header';
|
|
||||||
import { AdminSidebar } from './components/sidebar/sidebar';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, AdminHeader, AdminSidebar, AdminFooter],
|
imports: [RouterOutlet],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<footer class="admin-footer">
|
|
||||||
<span class="copyright">© {{ year }} APF France handicap</span>
|
|
||||||
<span class="surface-tag">Admin surface</span>
|
|
||||||
</footer>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.admin-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .admin-footer {
|
|
||||||
background-color: #0f172a;
|
|
||||||
border-top-color: #1f2937;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-tag {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-brand-accent-700, #b45309);
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .surface-tag {
|
|
||||||
color: var(--color-brand-accent-300, #fbbf24);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { AdminFooter } from './footer';
|
|
||||||
|
|
||||||
describe('AdminFooter', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({ imports: [AdminFooter] }).compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
function render(): HTMLElement {
|
|
||||||
const fixture = TestBed.createComponent(AdminFooter);
|
|
||||||
fixture.detectChanges();
|
|
||||||
return fixture.nativeElement as HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the APF copyright with the current year', () => {
|
|
||||||
const root = render();
|
|
||||||
const copy = root.querySelector('.copyright')?.textContent ?? '';
|
|
||||||
expect(copy).toContain('APF France handicap');
|
|
||||||
expect(copy).toContain(String(new Date().getFullYear()));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the "Admin surface" tag as a persistent visual reminder', () => {
|
|
||||||
const root = render();
|
|
||||||
expect(root.querySelector('.surface-tag')?.textContent?.trim()).toBe('Admin surface');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal admin-portal footer. Sits flush at the bottom of the
|
|
||||||
* shell with the copyright + a small "Admin" surface tag — same
|
|
||||||
* visual reminder as the header badge, since the footer is in
|
|
||||||
* view for any sufficiently long workload (audit-log paging,
|
|
||||||
* CMS editing) where the header may have scrolled out.
|
|
||||||
*
|
|
||||||
* No locale switcher, no accessibility-statement link in v1 — both
|
|
||||||
* land when the admin app gets full i18n / a11y plumbing of its
|
|
||||||
* own (currently inherited from the portal-shell baseline only).
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-footer',
|
|
||||||
templateUrl: './footer.html',
|
|
||||||
styleUrl: './footer.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class AdminFooter {
|
|
||||||
protected readonly year = new Date().getFullYear();
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<header class="admin-header">
|
|
||||||
<a class="brand" routerLink="/">
|
|
||||||
<img
|
|
||||||
src="logos/apf-logo.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
class="brand-logo"
|
|
||||||
/>
|
|
||||||
<span class="brand-wordmark">APF Portal</span>
|
|
||||||
<span class="brand-badge">Admin</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="auth-widget">
|
|
||||||
@switch (authState().kind) { @case ('loading') {
|
|
||||||
<span class="auth-status auth-status--loading" aria-live="polite">…</span>
|
|
||||||
} @case ('anonymous') {
|
|
||||||
<button type="button" class="btn btn--primary" (click)="signIn()">Sign in</button>
|
|
||||||
} @case ('error') {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn--secondary"
|
|
||||||
(click)="signIn()"
|
|
||||||
aria-label="Session unavailable — sign in again"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
} @case ('authenticated') { @if (authState(); as state) { @if (state.kind === 'authenticated') {
|
|
||||||
<lib-user-menu
|
|
||||||
[displayName]="state.user.displayName"
|
|
||||||
[username]="state.user.username"
|
|
||||||
[initials]="initials()"
|
|
||||||
[items]="userMenuItems"
|
|
||||||
[role]="roleLabel"
|
|
||||||
[signedInAsLabel]="signedInAsLabel"
|
|
||||||
[signOutLabel]="signOutLabel"
|
|
||||||
[triggerAriaLabel]="triggerAriaLabel(state.user.displayName)"
|
|
||||||
(signOut)="signOut()"
|
|
||||||
data-testid="user-menu"
|
|
||||||
/>
|
|
||||||
} } } }
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
// Lean admin header — single flex row, no collapse animation, no
|
|
||||||
// nested grids. Matches the `LayoutStateService`-driven sidebar's
|
|
||||||
// rail metrics so the brand sits flush above the navigation column.
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
background-color: var(--color-brand-primary-600, #1d4ed8);
|
|
||||||
color: #ffffff;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid #ffffff;
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-logo {
|
|
||||||
display: block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 1.75rem;
|
|
||||||
width: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Admin" pill — distinct color from the wordmark so it reads as a
|
|
||||||
// status badge even in dense headers. Keeps the surface
|
|
||||||
// immediately recognisable per ADR-0020 §"Discoverability for
|
|
||||||
// auditors".
|
|
||||||
.brand-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
background-color: var(--color-brand-accent-300, #f59e0b);
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-widget {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
// The admin header sits on `--color-brand-primary-600`; a brand
|
|
||||||
// primary avatar inside the user menu trigger would barely read.
|
|
||||||
// Override the shared avatar tokens to a translucent white block
|
|
||||||
// matching the inline avatar this widget replaced.
|
|
||||||
lib-user-menu {
|
|
||||||
--user-menu-avatar-bg: rgba(255, 255, 255, 0.2);
|
|
||||||
--user-menu-avatar-fg: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-status--loading {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-name {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid #ffffff;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--primary {
|
|
||||||
background-color: var(--color-brand-accent-300, #f59e0b);
|
|
||||||
color: #1f2937;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--secondary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: rgba(255, 255, 255, 0.4);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import {
|
|
||||||
AUTH_BFF_BASE_URL,
|
|
||||||
AUTH_NAVIGATOR,
|
|
||||||
AUTH_PATH_PREFIX,
|
|
||||||
type CurrentUser,
|
|
||||||
} from 'feature-auth';
|
|
||||||
import { AdminHeader } from './header';
|
|
||||||
|
|
||||||
const BFF_BASE = 'http://bff.test/api';
|
|
||||||
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
|
|
||||||
|
|
||||||
const USER: CurrentUser = {
|
|
||||||
oid: 'admin-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin Smith',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setup(opts: { onBootstrap: 'authenticated' | 'anonymous' | 'leave' }) {
|
|
||||||
const navigate = vi.fn();
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [AdminHeader],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
provideHttpClient(),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
|
||||||
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
|
||||||
{ provide: AUTH_NAVIGATOR, useValue: navigate },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const fixture = TestBed.createComponent(AdminHeader);
|
|
||||||
const http = TestBed.inject(HttpTestingController);
|
|
||||||
// AuthService fires the bootstrap /me from its constructor. Flush
|
|
||||||
// it before the first whenStable so the signal update has settled
|
|
||||||
// by the time we assert on the rendered DOM. `leave` keeps the
|
|
||||||
// loading state visible for tests that need it.
|
|
||||||
if (opts.onBootstrap !== 'leave') {
|
|
||||||
const req = http.expectOne(ADMIN_ME_URL);
|
|
||||||
if (opts.onBootstrap === 'authenticated') {
|
|
||||||
req.flush(USER);
|
|
||||||
} else {
|
|
||||||
req.flush({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
return { fixture, http, navigate };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdminHeader', () => {
|
|
||||||
it('renders the wordmark + admin badge', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('.brand-wordmark')?.textContent).toContain('APF Portal');
|
|
||||||
expect(root.querySelector('.brand-badge')?.textContent?.trim()).toBe('Admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a Sign in button when anonymous', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
|
||||||
const button = (fixture.nativeElement as HTMLElement).querySelector('.btn--primary');
|
|
||||||
expect(button?.textContent?.trim()).toBe('Sign in');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the user menu trigger with initials when authenticated', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'authenticated' });
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const menu = root.querySelector('[data-testid="user-menu"]');
|
|
||||||
expect(menu).not.toBeNull();
|
|
||||||
expect(menu?.querySelector('.user-menu__avatar')?.textContent?.trim()).toBe('AS');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens the menu and exposes displayName + Sign out inside the panel', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'authenticated' });
|
|
||||||
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
|
||||||
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
|
|
||||||
);
|
|
||||||
trigger?.click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
const panel = document.body.querySelector('.user-menu__panel');
|
|
||||||
expect(panel).not.toBeNull();
|
|
||||||
expect(panel?.querySelector('.user-menu__header-name')?.textContent).toContain('Admin Smith');
|
|
||||||
expect(panel?.querySelector('.user-menu__item--sign-out')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigates to the admin login URL on Sign in click', async () => {
|
|
||||||
const { fixture, navigate } = await setup({ onBootstrap: 'anonymous' });
|
|
||||||
(fixture.nativeElement as HTMLElement)
|
|
||||||
.querySelector<HTMLButtonElement>('.btn--primary')
|
|
||||||
?.click();
|
|
||||||
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigates to the admin logout URL on Sign out click inside the menu panel', async () => {
|
|
||||||
const { fixture, navigate } = await setup({ onBootstrap: 'authenticated' });
|
|
||||||
const trigger = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
|
||||||
'[data-testid="user-menu"] button[aria-haspopup="menu"]',
|
|
||||||
);
|
|
||||||
trigger?.click();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
document.body.querySelector<HTMLButtonElement>('.user-menu__item--sign-out')?.click();
|
|
||||||
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/logout`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
import { AuthService, type CurrentUser } from 'feature-auth';
|
|
||||||
import { UserMenu, type UserMenuItem } from 'shared-ui';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin-portal header. Deliberately leaner than the user-portal
|
|
||||||
* counterpart per ADR-0020 §"UX style is data-dense" — admins land
|
|
||||||
* on tabular workloads, not a discovery dashboard, so we drop the
|
|
||||||
* global search box, notification cluster, and help widget for v1.
|
|
||||||
*
|
|
||||||
* Two anchor points:
|
|
||||||
* - The "Admin" badge next to the wordmark, so an internal user
|
|
||||||
* instantly sees they're on the elevated surface (defense
|
|
||||||
* against "thought I was on portal-shell, just deleted a CMS
|
|
||||||
* page" muscle-memory misfires).
|
|
||||||
* - The auth widget: signed in → avatar with initials + Sign out,
|
|
||||||
* signed out → a Sign in button that 302s through the admin
|
|
||||||
* OIDC flow (`/api/admin/auth/login`). State driven by the
|
|
||||||
* shared `feature-auth` `AuthService` with `AUTH_PATH_PREFIX`
|
|
||||||
* overridden to `/admin/auth` (see `app.config.ts`).
|
|
||||||
*
|
|
||||||
* No locale switcher in v1 — admin users are a small known set
|
|
||||||
* and the source-locale-only chrome keeps the bundle leaner.
|
|
||||||
* Promotion when the editorial team needs FR/EN parity in the
|
|
||||||
* admin UI itself.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-header',
|
|
||||||
imports: [RouterLink, UserMenu],
|
|
||||||
templateUrl: './header.html',
|
|
||||||
styleUrl: './header.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class AdminHeader {
|
|
||||||
private readonly auth = inject(AuthService);
|
|
||||||
|
|
||||||
protected readonly authState = this.auth.state;
|
|
||||||
|
|
||||||
protected readonly initials = computed(() => {
|
|
||||||
const user = this.auth.currentUser();
|
|
||||||
return user ? initialsFor(user) : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Static menu rows. Same shape as the portal-shell menu so a user
|
|
||||||
// who learned one finds the other familiar. The "Open Portal
|
|
||||||
// Shell" entry is unconditional here — the user-portal is public,
|
|
||||||
// any admin can jump back to it.
|
|
||||||
protected readonly userMenuItems: readonly UserMenuItem[] = [
|
|
||||||
{ label: 'Profile', icon: 'user', routerLink: '/profile' },
|
|
||||||
{ label: 'Settings', icon: 'settings', disabled: true, badge: 'Soon' },
|
|
||||||
{ label: 'Open Portal Shell', icon: 'home', href: environment.shellAppUrl },
|
|
||||||
];
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected signIn(): void {
|
|
||||||
this.auth.login();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected signOut(): void {
|
|
||||||
this.auth.logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialsFor(user: CurrentUser): string {
|
|
||||||
const name = (user.displayName || user.username).trim();
|
|
||||||
if (!name) {
|
|
||||||
return '?';
|
|
||||||
}
|
|
||||||
const parts = name.split(/\s+/).filter(Boolean).slice(0, 2);
|
|
||||||
const letters = parts.map((p) => p.charAt(0).toUpperCase()).join('');
|
|
||||||
return letters || name.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<nav class="admin-sidebar" aria-label="Admin navigation">
|
|
||||||
<ul class="nav-list">
|
|
||||||
@for (item of menu; track item.label) {
|
|
||||||
<li class="nav-item">
|
|
||||||
@if (item.routerLink !== null) {
|
|
||||||
<a
|
|
||||||
class="nav-link"
|
|
||||||
[routerLink]="item.routerLink"
|
|
||||||
routerLinkActive="nav-link--active"
|
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
|
||||||
>
|
|
||||||
<span class="nav-icon"><lib-icon [name]="item.icon" [size]="18" /></span>
|
|
||||||
<span class="nav-label">{{ item.label }}</span>
|
|
||||||
</a>
|
|
||||||
} @else {
|
|
||||||
<span class="nav-link nav-link--disabled" aria-disabled="true">
|
|
||||||
<span class="nav-icon"><lib-icon [name]="item.icon" [size]="18" /></span>
|
|
||||||
<span class="nav-label">{{ item.label }}</span>
|
|
||||||
<span class="nav-badge">Soon</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// The host element owns the sidebar envelope (width + chrome) so
|
|
||||||
// the gray background + right border cover the full column, not
|
|
||||||
// just the rail of links. The inner <nav> handles the scrolling
|
|
||||||
// list inside that envelope.
|
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 16rem;
|
|
||||||
flex: 0 0 16rem;
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
border-right: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) {
|
|
||||||
background-color: #0f172a;
|
|
||||||
border-right-color: #1f2937;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: #1f2937;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .nav-link {
|
|
||||||
color: #e5e7eb;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link--active {
|
|
||||||
background-color: rgba(29, 78, 216, 0.12);
|
|
||||||
color: var(--color-brand-primary-700, #1e40af);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .nav-link--active {
|
|
||||||
background-color: rgba(96, 165, 250, 0.18);
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link--disabled {
|
|
||||||
color: #9ca3af;
|
|
||||||
cursor: default;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .nav-link--disabled {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 1.25rem;
|
|
||||||
flex: 0 0 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-badge {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .nav-badge {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { AdminSidebar } from './sidebar';
|
|
||||||
|
|
||||||
describe('AdminSidebar', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [AdminSidebar],
|
|
||||||
providers: [provideRouter([])],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
function render(): HTMLElement {
|
|
||||||
const fixture = TestBed.createComponent(AdminSidebar);
|
|
||||||
fixture.detectChanges();
|
|
||||||
return fixture.nativeElement as HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders a navigation landmark labelled "Admin navigation"', () => {
|
|
||||||
const root = render();
|
|
||||||
const nav = root.querySelector('nav');
|
|
||||||
expect(nav?.getAttribute('aria-label')).toBe('Admin navigation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the Audit log entry as a live router link', () => {
|
|
||||||
const root = render();
|
|
||||||
const links = Array.from(root.querySelectorAll<HTMLAnchorElement>('a.nav-link'));
|
|
||||||
const auditLink = links.find((a) => a.textContent?.includes('Audit log'));
|
|
||||||
expect(auditLink).toBeTruthy();
|
|
||||||
// RouterLink renders the resolved href on the anchor.
|
|
||||||
expect(auditLink?.getAttribute('href')).toBe('/audit');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the User list entry as a live router link', () => {
|
|
||||||
const root = render();
|
|
||||||
const links = Array.from(root.querySelectorAll<HTMLAnchorElement>('a.nav-link'));
|
|
||||||
const usersLink = links.find((a) => a.textContent?.includes('User list'));
|
|
||||||
expect(usersLink).toBeTruthy();
|
|
||||||
expect(usersLink?.getAttribute('href')).toBe('/users');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the not-yet-implemented entries as aria-disabled placeholders with a "Soon" badge', () => {
|
|
||||||
const root = render();
|
|
||||||
const disabled = Array.from(root.querySelectorAll('.nav-link--disabled'));
|
|
||||||
expect(disabled.length).toBeGreaterThan(0);
|
|
||||||
for (const node of disabled) {
|
|
||||||
expect(node.getAttribute('aria-disabled')).toBe('true');
|
|
||||||
expect(node.querySelector('.nav-badge')?.textContent?.trim()).toBe('Soon');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
|
||||||
import { Icon, type IconName } from 'shared-ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin-side navigation. v1 lists the four modules from ADR-0020's
|
|
||||||
* v1 admin catalogue (CMS, menu management, user list, audit log
|
|
||||||
* viewer). Only the audit-log link points at a live route in this
|
|
||||||
* PR; the rest land as routerLink placeholders disabled with
|
|
||||||
* `aria-disabled`, so the navigation shape is established even
|
|
||||||
* before the modules ship.
|
|
||||||
*
|
|
||||||
* No collapse mode (the portal-admin v1 layout doesn't need to
|
|
||||||
* compete with content for screen real estate the way the user
|
|
||||||
* portal does). When that changes we promote the
|
|
||||||
* `LayoutStateService.sidebarCollapsed` signal here too.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface AdminMenuItem {
|
|
||||||
readonly label: string;
|
|
||||||
readonly icon: IconName;
|
|
||||||
/** Live route once the module ships; placeholder when null. */
|
|
||||||
readonly routerLink: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MENU: readonly AdminMenuItem[] = [
|
|
||||||
{ label: 'Audit log', icon: 'file-text', routerLink: '/audit' },
|
|
||||||
{ label: 'CMS pages', icon: 'folder', routerLink: null },
|
|
||||||
{ label: 'Menu management', icon: 'layout-dashboard', routerLink: null },
|
|
||||||
{ label: 'User list', icon: 'building-2', routerLink: '/users' },
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-sidebar',
|
|
||||||
imports: [RouterLink, RouterLinkActive, Icon],
|
|
||||||
templateUrl: './sidebar.html',
|
|
||||||
styleUrl: './sidebar.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class AdminSidebar {
|
|
||||||
protected readonly menu = MENU;
|
|
||||||
}
|
|
||||||
@@ -1,90 +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 { AuditEventsService, type AdminAuditPage } from './audit-events.service';
|
|
||||||
|
|
||||||
const BFF_BASE = 'http://bff.test/api';
|
|
||||||
const AUDIT_URL = `${BFF_BASE}/admin/audit`;
|
|
||||||
|
|
||||||
const PAGE: AdminAuditPage = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: '11111111-1111-1111-1111-111111111111',
|
|
||||||
createdAt: '2026-05-13T10:30:00.000Z',
|
|
||||||
eventType: 'auth.sign_in',
|
|
||||||
audience: 'workforce',
|
|
||||||
actorIdHash: 'hash(jane)',
|
|
||||||
traceId: 'trace-abc',
|
|
||||||
subject: 'session:sid-1',
|
|
||||||
outcome: 'success',
|
|
||||||
payload: { amr: ['pwd', 'mfa'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
service: TestBed.inject(AuditEventsService),
|
|
||||||
http: TestBed.inject(HttpTestingController),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AuditEventsService.query', () => {
|
|
||||||
it('GETs /admin/audit with the populated filter fields as URL params', async () => {
|
|
||||||
const { service, http } = setup();
|
|
||||||
const promise = firstValueFrom(
|
|
||||||
service.query({
|
|
||||||
eventType: 'auth.sign_in',
|
|
||||||
audience: 'workforce',
|
|
||||||
outcome: 'success',
|
|
||||||
limit: 25,
|
|
||||||
offset: 50,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const req = http.expectOne((r) => r.url === AUDIT_URL);
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
expect(req.request.params.get('eventType')).toBe('auth.sign_in');
|
|
||||||
expect(req.request.params.get('audience')).toBe('workforce');
|
|
||||||
expect(req.request.params.get('outcome')).toBe('success');
|
|
||||||
expect(req.request.params.get('limit')).toBe('25');
|
|
||||||
expect(req.request.params.get('offset')).toBe('50');
|
|
||||||
req.flush(PAGE);
|
|
||||||
await expect(promise).resolves.toEqual(PAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits filter fields that are empty strings (BFF rejects `?foo=` as invalid)', async () => {
|
|
||||||
const { service, http } = setup();
|
|
||||||
void firstValueFrom(
|
|
||||||
service.query({
|
|
||||||
eventType: '',
|
|
||||||
subjectPrefix: '',
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const req = http.expectOne((r) => r.url === AUDIT_URL);
|
|
||||||
expect(req.request.params.has('eventType')).toBe(false);
|
|
||||||
expect(req.request.params.has('subjectPrefix')).toBe(false);
|
|
||||||
expect(req.request.params.get('limit')).toBe('50');
|
|
||||||
req.flush(PAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits undefined filter fields', async () => {
|
|
||||||
const { service, http } = setup();
|
|
||||||
void firstValueFrom(service.query({}));
|
|
||||||
const req = http.expectOne((r) => r.url === AUDIT_URL);
|
|
||||||
expect(req.request.params.keys().length).toBe(0);
|
|
||||||
req.flush(PAGE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
|
||||||
import type { Observable } from 'rxjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of the BFF's `GET /api/admin/audit` response. Mirrors
|
|
||||||
* `AdminAuditEventDto` on the BFF side — ISO-string timestamp,
|
|
||||||
* already-parsed JSON payload, hashed actor id.
|
|
||||||
*/
|
|
||||||
export interface AdminAuditEvent {
|
|
||||||
readonly id: string;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly eventType: string;
|
|
||||||
readonly audience: string;
|
|
||||||
readonly actorIdHash: string | null;
|
|
||||||
readonly traceId: string | null;
|
|
||||||
readonly subject: string | null;
|
|
||||||
readonly outcome: string;
|
|
||||||
readonly payload: Record<string, unknown> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Paginated response shape — mirrors `AdminAuditPage` on the BFF. */
|
|
||||||
export interface AdminAuditPage {
|
|
||||||
readonly items: readonly AdminAuditEvent[];
|
|
||||||
readonly total: number;
|
|
||||||
readonly limit: number;
|
|
||||||
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,
|
|
||||||
* offset=0, no filter).
|
|
||||||
*/
|
|
||||||
export interface AdminAuditQuery {
|
|
||||||
// Both optional AND explicitly nullable: `exactOptionalPropertyTypes`
|
|
||||||
// rejects assigning a `T | undefined` to a `?: T`, but the
|
|
||||||
// AuditPage `buildFilters()` builds objects with `undefined`
|
|
||||||
// placeholders for missing user input. Allowing both `?:` and
|
|
||||||
// `| undefined` lets callers omit the key entirely OR set it to
|
|
||||||
// undefined, both meaning "no filter".
|
|
||||||
readonly eventType?: string | undefined;
|
|
||||||
readonly actorIdHash?: string | undefined;
|
|
||||||
readonly audience?: 'workforce' | 'customer' | undefined;
|
|
||||||
readonly outcome?: 'success' | 'failure' | 'denied' | undefined;
|
|
||||||
readonly subjectPrefix?: string | undefined;
|
|
||||||
readonly createdAtFrom?: string | undefined;
|
|
||||||
readonly createdAtTo?: string | undefined;
|
|
||||||
readonly limit?: number | undefined;
|
|
||||||
readonly offset?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin HttpClient wrapper. Lives in `providedIn: 'root'` because the
|
|
||||||
* audit page is the single v1 consumer and per-page DI overhead is
|
|
||||||
* not worth it. The wrapper exists at all (vs HttpClient directly in
|
|
||||||
* the component) so the audit page's logic stays signal-only and the
|
|
||||||
* spec can mock the service rather than threading
|
|
||||||
* `HttpTestingController` through every assertion.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class AuditEventsService {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
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=`
|
|
||||||
// as `foo === ''` which trips the IsISO8601 / IsString
|
|
||||||
// validators and surfaces as a 400. The SPA passes empty
|
|
||||||
// inputs when the user hasn't filled a filter; this strips
|
|
||||||
// them out so the BFF only sees populated fields.
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
params = params.set(key, String(value));
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
<section class="audit">
|
|
||||||
<header class="audit-header">
|
|
||||||
<h1 class="title">Audit log</h1>
|
|
||||||
<p class="intro">
|
|
||||||
Read-only view of <code>audit.events</code> per
|
|
||||||
<a
|
|
||||||
href="https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0013-audit-trail-separated-postgres-append-only.md"
|
|
||||||
rel="noopener"
|
|
||||||
target="_blank"
|
|
||||||
>ADR-0013</a
|
|
||||||
>. Every query run on this page emits its own <code>admin.audit.query</code> row — read access
|
|
||||||
is itself auditable.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form class="filters" (submit)="$event.preventDefault(); search()" aria-labelledby="filters-h">
|
|
||||||
<h2 id="filters-h" class="filters-heading">Filters</h2>
|
|
||||||
<div class="filters-grid">
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Event type</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="eventType"
|
|
||||||
[ngModel]="eventType()"
|
|
||||||
(ngModelChange)="eventType.set($event)"
|
|
||||||
placeholder="auth.sign_in"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Actor id hash</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="actorIdHash"
|
|
||||||
[ngModel]="actorIdHash()"
|
|
||||||
(ngModelChange)="actorIdHash.set($event)"
|
|
||||||
placeholder="64 hex chars"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Audience</span>
|
|
||||||
<select name="audience" [ngModel]="audience()" (ngModelChange)="audience.set($event)">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="workforce">workforce</option>
|
|
||||||
<option value="customer">customer</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Outcome</span>
|
|
||||||
<select name="outcome" [ngModel]="outcome()" (ngModelChange)="outcome.set($event)">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="success">success</option>
|
|
||||||
<option value="failure">failure</option>
|
|
||||||
<option value="denied">denied</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Subject prefix</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="subjectPrefix"
|
|
||||||
[ngModel]="subjectPrefix()"
|
|
||||||
(ngModelChange)="subjectPrefix.set($event)"
|
|
||||||
placeholder="session:"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">From</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
name="createdAtFrom"
|
|
||||||
[ngModel]="createdAtFrom()"
|
|
||||||
(ngModelChange)="createdAtFrom.set($event)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">To</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
name="createdAtTo"
|
|
||||||
[ngModel]="createdAtTo()"
|
|
||||||
(ngModelChange)="createdAtTo.set($event)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Page size</span>
|
|
||||||
<select name="limit" [ngModel]="limit()" (ngModelChange)="limit.set(+$event)">
|
|
||||||
@for (size of pageSizes; track size) {
|
|
||||||
<option [value]="size">{{ size }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="filters-actions">
|
|
||||||
<button type="submit" class="btn btn--primary" [disabled]="loading()">Apply filters</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn--secondary"
|
|
||||||
(click)="clearFilters()"
|
|
||||||
[disabled]="loading()"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</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()) {
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
.audit {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0.0625rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--color-brand-primary-600, #2563eb);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .intro {
|
|
||||||
color: #9ca3af;
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: var(--color-brand-primary-300, #93c5fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .filters {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-heading {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .filter-label {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter input,
|
|
||||||
.filter select {
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: #ffffff;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .filter input,
|
|
||||||
:host-context(.dark) .filter select {
|
|
||||||
background-color: #0f172a;
|
|
||||||
border-color: #374151;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 2.25rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--primary {
|
|
||||||
background-color: var(--color-brand-primary-600, #2563eb);
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-brand-primary-700, #1d4ed8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--secondary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #d1d5db;
|
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .btn--secondary:hover:not(:disabled) {
|
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
min-height: 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-line--error {
|
|
||||||
color: #b91c1c;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .status-bar {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .status-line--error {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
color: #6b7280;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px dashed #d1d5db;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .empty {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #374151;
|
|
||||||
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;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .table-wrap {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .audit-table {
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border-bottom-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: #1f2937;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-timestamp {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-event,
|
|
||||||
.cell-trace,
|
|
||||||
.actor-hash,
|
|
||||||
.subject {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-trace,
|
|
||||||
.actor-hash,
|
|
||||||
.subject {
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actor-hash {
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subject {
|
|
||||||
color: #6b7280;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .actor-hash {
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .subject {
|
|
||||||
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;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aud-badge {
|
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outcome-badge--success {
|
|
||||||
background-color: rgba(34, 197, 94, 0.18);
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outcome-badge--failure {
|
|
||||||
background-color: rgba(239, 68, 68, 0.18);
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outcome-badge--denied {
|
|
||||||
background-color: rgba(234, 179, 8, 0.22);
|
|
||||||
color: #854d0e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) {
|
|
||||||
.aud-badge {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.outcome-badge--success {
|
|
||||||
background-color: rgba(34, 197, 94, 0.22);
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
.outcome-badge--failure {
|
|
||||||
background-color: rgba(239, 68, 68, 0.22);
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.outcome-badge--denied {
|
|
||||||
background-color: rgba(234, 179, 8, 0.22);
|
|
||||||
color: #fde68a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-payload {
|
|
||||||
details {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
color: var(--color-brand-primary-600, #2563eb);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .cell-payload {
|
|
||||||
summary {
|
|
||||||
color: var(--color-brand-primary-300, #93c5fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dim {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import { AuditPage } from './audit';
|
|
||||||
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: [
|
|
||||||
{
|
|
||||||
id: '11111111-1111-1111-1111-111111111111',
|
|
||||||
createdAt: '2026-05-13T10:30:00.000Z',
|
|
||||||
eventType: 'auth.sign_in',
|
|
||||||
audience: 'workforce',
|
|
||||||
actorIdHash: 'hash(jane)',
|
|
||||||
traceId: 'trace-abc',
|
|
||||||
subject: 'session:sid-1',
|
|
||||||
outcome: 'success',
|
|
||||||
payload: { amr: ['pwd', 'mfa'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '22222222-2222-2222-2222-222222222222',
|
|
||||||
createdAt: '2026-05-13T10:31:00.000Z',
|
|
||||||
eventType: 'admin.access_denied',
|
|
||||||
audience: 'workforce',
|
|
||||||
actorIdHash: 'hash(mallory)',
|
|
||||||
traceId: null,
|
|
||||||
subject: 'GET /api/admin/me',
|
|
||||||
outcome: 'denied',
|
|
||||||
payload: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 247,
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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 } }],
|
|
||||||
});
|
|
||||||
const fixture = TestBed.createComponent(AuditPage);
|
|
||||||
return { fixture, query, stats };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flush(fixture: Fixture['fixture']): Promise<void> {
|
|
||||||
// Drain microtasks twice — the initial fetch fires from ngOnInit
|
|
||||||
// and awaits firstValueFrom (one tick) then updates the signal
|
|
||||||
// (another tick) before the DOM reflects the result.
|
|
||||||
fixture.detectChanges();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AuditPage', () => {
|
|
||||||
it('runs an initial unfiltered query on init and renders the result table', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
expect(query).toHaveBeenCalledTimes(1);
|
|
||||||
expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 }));
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
|
|
||||||
expect(rows.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the result range ("1–2 of 247") in the status bar', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
expect(
|
|
||||||
(fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
|
|
||||||
).toContain('1–2 of 247');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the empty state when the page has no items', async () => {
|
|
||||||
const { fixture } = setup({
|
|
||||||
initial: { items: [], total: 0, limit: 50, offset: 0 },
|
|
||||||
});
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('.empty')?.textContent).toContain('No audit events');
|
|
||||||
expect(root.querySelector('.audit-table')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an error message when the service rejects (500)', async () => {
|
|
||||||
const { fixture } = setup({ initial: 'error', status: 500 });
|
|
||||||
await flush(fixture);
|
|
||||||
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
|
|
||||||
expect(status?.textContent).toContain('Could not load the audit log');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a permission-aware error message on 403', async () => {
|
|
||||||
const { fixture } = setup({ initial: 'error', status: 403 });
|
|
||||||
await flush(fixture);
|
|
||||||
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
|
|
||||||
expect(status?.textContent).toContain('do not have access');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the populated filter fields to the service on Apply', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const component = fixture.componentInstance;
|
|
||||||
// Simulate user input by setting the signals directly.
|
|
||||||
(component as unknown as { eventType: { set: (v: string) => void } }).eventType.set(
|
|
||||||
'auth.sign_in',
|
|
||||||
);
|
|
||||||
(component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce');
|
|
||||||
await component.search();
|
|
||||||
await flush(fixture);
|
|
||||||
expect(query).toHaveBeenCalledTimes(2);
|
|
||||||
const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery;
|
|
||||||
expect(lastCall.eventType).toBe('auth.sign_in');
|
|
||||||
expect(lastCall.audience).toBe('workforce');
|
|
||||||
expect(lastCall.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets the offset to 0 on each Apply (no stale page when filters narrow)', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const component = fixture.componentInstance;
|
|
||||||
// Advance to next page.
|
|
||||||
await component.next();
|
|
||||||
await flush(fixture);
|
|
||||||
expect((query.mock.calls[1]?.[0] as AdminAuditQuery).offset).toBe(50);
|
|
||||||
// Apply a new filter — offset must drop back to 0.
|
|
||||||
await component.search();
|
|
||||||
await flush(fixture);
|
|
||||||
expect((query.mock.calls[2]?.[0] as AdminAuditQuery).offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearFilters empties every filter signal and re-queries from offset 0', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const component = fixture.componentInstance;
|
|
||||||
(component as unknown as { eventType: { set: (v: string) => void } }).eventType.set('x');
|
|
||||||
(component as unknown as { outcome: { set: (v: string) => void } }).outcome.set('denied');
|
|
||||||
await component.clearFilters();
|
|
||||||
await flush(fixture);
|
|
||||||
const lastCall = query.mock.calls[1]?.[0] as AdminAuditQuery;
|
|
||||||
expect(lastCall.eventType).toBeUndefined();
|
|
||||||
expect(lastCall.outcome).toBeUndefined();
|
|
||||||
expect(lastCall.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Next is disabled when the loaded page covers the total', async () => {
|
|
||||||
const { fixture } = setup({
|
|
||||||
initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 },
|
|
||||||
});
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const nextBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
|
|
||||||
(b) => b.textContent?.trim() === 'Next',
|
|
||||||
);
|
|
||||||
expect(nextBtn?.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Previous is disabled at offset 0', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const prevBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
|
|
||||||
(b) => b.textContent?.trim() === 'Previous',
|
|
||||||
);
|
|
||||||
expect(prevBtn?.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the outcome badge variant matching the row', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
|
|
||||||
expect(rows[0]?.querySelector('.outcome-badge--success')).not.toBeNull();
|
|
||||||
expect(rows[1]?.querySelector('.outcome-badge--denied')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the payload disclosure only for rows that have a payload', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const rows = root.querySelectorAll<HTMLTableRowElement>('.audit-table tbody tr');
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit log viewer per ADR-0020's v1 admin catalogue. Consumes
|
|
||||||
* `GET /api/admin/audit` from the BFF (PR #132) and renders:
|
|
||||||
*
|
|
||||||
* - A filter row binding to the same fields the BFF accepts
|
|
||||||
* (event type, audience, outcome, subject prefix, time range).
|
|
||||||
* - A page-size selector + previous / next buttons. Pagination is
|
|
||||||
* offset-based to match the BFF's v1 implementation.
|
|
||||||
* - A table of results with the timestamp (locale-formatted),
|
|
||||||
* event type, audience+outcome cell, actor hash + subject, and
|
|
||||||
* trace id. Payload is rendered as JSON in a details disclosure
|
|
||||||
* so the table stays scannable but the structured detail is one
|
|
||||||
* click away.
|
|
||||||
*
|
|
||||||
* The page emits its own `admin.audit.query` audit row server-side on
|
|
||||||
* every fetch — the BFF deterrent against fishing expeditions is on
|
|
||||||
* the read endpoint itself, not in the SPA.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-audit-page',
|
|
||||||
imports: [FormsModule, BarChart, DonutChart, StackedBarChart],
|
|
||||||
templateUrl: './audit.html',
|
|
||||||
styleUrl: './audit.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class AuditPage {
|
|
||||||
private readonly service = inject(AuditEventsService);
|
|
||||||
|
|
||||||
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
|
|
||||||
// events on an unfiltered query.
|
|
||||||
protected readonly eventType = signal('');
|
|
||||||
protected readonly actorIdHash = signal('');
|
|
||||||
protected readonly audience = signal<'' | 'workforce' | 'customer'>('');
|
|
||||||
protected readonly outcome = signal<'' | 'success' | 'failure' | 'denied'>('');
|
|
||||||
protected readonly subjectPrefix = signal('');
|
|
||||||
protected readonly createdAtFrom = signal('');
|
|
||||||
protected readonly createdAtTo = signal('');
|
|
||||||
|
|
||||||
protected readonly limit = signal<number>(50);
|
|
||||||
protected readonly offset = signal<number>(0);
|
|
||||||
|
|
||||||
// Result state. `page` is the current server response; `loading`
|
|
||||||
// is true while a fetch is in flight; `error` carries a short
|
|
||||||
// string for the alert region.
|
|
||||||
protected readonly page = signal<AdminAuditPage | null>(null);
|
|
||||||
protected readonly loading = signal(false);
|
|
||||||
protected readonly error = signal<string | null>(null);
|
|
||||||
|
|
||||||
/** True when the loaded page does NOT cover the next offset boundary. */
|
|
||||||
protected readonly hasNextPage = computed(() => {
|
|
||||||
const p = this.page();
|
|
||||||
if (p === null) return false;
|
|
||||||
return p.offset + p.items.length < p.total;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly hasPreviousPage = computed(() => {
|
|
||||||
const p = this.page();
|
|
||||||
if (p === null) return false;
|
|
||||||
return p.offset > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Human-readable "results 1–50 of 1 234" string. Defensive — when
|
|
||||||
// the page is empty we report "no results" rather than "1–0".
|
|
||||||
protected readonly resultRange = computed(() => {
|
|
||||||
const p = this.page();
|
|
||||||
if (p === null) return '';
|
|
||||||
if (p.items.length === 0) return p.total === 0 ? 'No results' : `0 of ${p.total}`;
|
|
||||||
const first = p.offset + 1;
|
|
||||||
const last = p.offset + p.items.length;
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Page navigation — moves the offset by `limit`, then fetches. */
|
|
||||||
async next(): Promise<void> {
|
|
||||||
if (!this.hasNextPage()) return;
|
|
||||||
this.offset.update((o) => o + this.limit());
|
|
||||||
await this.fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async previous(): Promise<void> {
|
|
||||||
if (!this.hasPreviousPage()) return;
|
|
||||||
this.offset.update((o) => Math.max(0, o - this.limit()));
|
|
||||||
await this.fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset every filter to its empty value and re-query. */
|
|
||||||
async clearFilters(): Promise<void> {
|
|
||||||
this.eventType.set('');
|
|
||||||
this.actorIdHash.set('');
|
|
||||||
this.audience.set('');
|
|
||||||
this.outcome.set('');
|
|
||||||
this.subjectPrefix.set('');
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format an ISO timestamp into the user's locale for the table. */
|
|
||||||
protected formatTimestamp(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
// Fall back to the raw ISO string if Date refuses it — better
|
|
||||||
// than rendering "Invalid Date" in the table.
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stringify the JSONB payload for the disclosure widget. */
|
|
||||||
protected formatPayload(payload: Record<string, unknown> | null): string {
|
|
||||||
if (payload === null) return '—';
|
|
||||||
try {
|
|
||||||
return JSON.stringify(payload, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
try {
|
|
||||||
const filters = this.buildFilters();
|
|
||||||
const page = await firstValueFrom(this.service.query(filters));
|
|
||||||
this.page.set(page);
|
|
||||||
} catch (err) {
|
|
||||||
// Don't surface the raw error message to the admin — it's
|
|
||||||
// either a 401 (handled by the unauthorized interceptor), a
|
|
||||||
// 403 (admin role revoked mid-session), or a 5xx. A short
|
|
||||||
// generic string keeps the UI predictable; the structured
|
|
||||||
// error envelope is in the network tab for ops.
|
|
||||||
const status = errorStatus(err);
|
|
||||||
this.error.set(
|
|
||||||
status === 403
|
|
||||||
? 'You do not have access to the audit log on this session.'
|
|
||||||
: 'Could not load the audit log. Try again in a moment.',
|
|
||||||
);
|
|
||||||
this.page.set(null);
|
|
||||||
} finally {
|
|
||||||
this.loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFilters(): AdminAuditQuery {
|
|
||||||
return {
|
|
||||||
eventType: this.eventType().trim() || undefined,
|
|
||||||
actorIdHash: this.actorIdHash().trim() || undefined,
|
|
||||||
audience: this.audience() || undefined,
|
|
||||||
outcome: this.outcome() || undefined,
|
|
||||||
subjectPrefix: this.subjectPrefix().trim() || undefined,
|
|
||||||
createdAtFrom: this.createdAtFrom() ? toIso(this.createdAtFrom()) : undefined,
|
|
||||||
createdAtTo: this.createdAtTo() ? toIso(this.createdAtTo()) : undefined,
|
|
||||||
limit: this.limit(),
|
|
||||||
offset: this.offset(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
// page handles its own loading / error state internally.
|
|
||||||
void this.fetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIso(localValue: string): string {
|
|
||||||
// `<input type="datetime-local">` emits `YYYY-MM-DDTHH:mm` (no
|
|
||||||
// timezone). Treat that as the user's local time and convert to
|
|
||||||
// a true ISO 8601 string so the BFF's IsISO8601 validator accepts
|
|
||||||
// it. Falls back to the raw value if Date can't parse it (the
|
|
||||||
// BFF will then return a 400 with a clear validation error).
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,24 @@
|
|||||||
<section class="home">
|
<section class="mx-auto max-w-3xl px-6 py-12">
|
||||||
<h1 class="title" i18n="@@page.home.title">APF Portal Admin</h1>
|
<h1
|
||||||
<p class="intro">
|
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
|
||||||
Administrative back-office for the APF Portal. Sign in with an Entra account that carries the
|
i18n="@@page.home.title"
|
||||||
<code>admin</code> app role to reach the functional modules.
|
>
|
||||||
|
APF Portal Admin
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
|
||||||
|
i18n="@@page.home.intro"
|
||||||
|
>
|
||||||
|
Administrative back-office for the APF Portal. The first functional modules — content
|
||||||
|
management, menu administration, user list, audit log viewer — land in upcoming PRs per
|
||||||
|
ADR-0020.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<article class="auth-card" aria-live="polite">
|
<p
|
||||||
@switch (state().kind) { @case ('loading') {
|
class="mt-6 inline-flex items-center rounded-full border border-brand-accent-300 bg-brand-accent-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand-accent-700 dark:border-brand-accent-700 dark:bg-brand-accent-950/40 dark:text-brand-accent-200"
|
||||||
<p class="status-line">Checking your admin session…</p>
|
role="status"
|
||||||
} @case ('anonymous') {
|
i18n="@@page.home.status"
|
||||||
<p class="status-line">No admin session detected.</p>
|
>
|
||||||
<button type="button" class="btn btn--primary" (click)="signIn()">Sign in via Entra</button>
|
Skeleton — coming soon
|
||||||
} @case ('error') {
|
</p>
|
||||||
<p class="status-line status-line--error">Could not reach the BFF to resolve the session.</p>
|
|
||||||
<button type="button" class="btn btn--secondary" (click)="signIn()">Try sign in</button>
|
|
||||||
} @case ('authenticated') { @if (currentUser(); as user) {
|
|
||||||
<p class="status-line status-line--ok">Signed in as <strong>{{ user.displayName }}</strong>.</p>
|
|
||||||
<dl class="session-detail">
|
|
||||||
<dt>Username</dt>
|
|
||||||
<dd>{{ user.username }}</dd>
|
|
||||||
<dt>Tenant</dt>
|
|
||||||
<dd>{{ user.tid }}</dd>
|
|
||||||
<dt>OID</dt>
|
|
||||||
<dd>{{ user.oid }}</dd>
|
|
||||||
</dl>
|
|
||||||
} } }
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<section class="roadmap" aria-labelledby="roadmap-heading">
|
|
||||||
<h2 id="roadmap-heading" class="roadmap-heading">Coming next</h2>
|
|
||||||
<ul class="roadmap-list">
|
|
||||||
<li>
|
|
||||||
<strong>Audit log viewer</strong> — paginated, filterable view of <code>audit.events</code>.
|
|
||||||
</li>
|
|
||||||
<li><strong>CMS pages</strong> — CRUD on editorial content per locale (ADR-0019).</li>
|
|
||||||
<li><strong>Menu management</strong> — toggle items + role-gate them.</li>
|
|
||||||
<li><strong>User list</strong> — read-only directory derived from sign-in events.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
.home {
|
|
||||||
max-width: 56rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
margin: 0.75rem 0 1.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #4b5563;
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0.0625rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .intro {
|
|
||||||
color: #d1d5db;
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
padding: 1.25rem;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .auth-card {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-line {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-line--ok {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-line--error {
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .status-line--ok {
|
|
||||||
color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .status-line--error {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-detail {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 8rem 1fr;
|
|
||||||
gap: 0.25rem 1rem;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .session-detail dt {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 2.25rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--primary {
|
|
||||||
background-color: var(--color-brand-primary-600, #2563eb);
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-brand-primary-700, #1d4ed8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--secondary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #d1d5db;
|
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap {
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .roadmap {
|
|
||||||
border-top-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap-heading {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap-list {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import {
|
|
||||||
AUTH_BFF_BASE_URL,
|
|
||||||
AUTH_NAVIGATOR,
|
|
||||||
AUTH_PATH_PREFIX,
|
|
||||||
type CurrentUser,
|
|
||||||
} from 'feature-auth';
|
|
||||||
import { Home } from './home';
|
|
||||||
|
|
||||||
const BFF_BASE = 'http://bff.test/api';
|
|
||||||
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
|
|
||||||
|
|
||||||
const USER: CurrentUser = {
|
|
||||||
oid: 'admin-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin Smith',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setup(opts: { onBootstrap: 'authenticated' | 'anonymous' }) {
|
|
||||||
const navigate = vi.fn();
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Home],
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
|
||||||
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
|
||||||
{ provide: AUTH_NAVIGATOR, useValue: navigate },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const fixture = TestBed.createComponent(Home);
|
|
||||||
const http = TestBed.inject(HttpTestingController);
|
|
||||||
const req = http.expectOne(ADMIN_ME_URL);
|
|
||||||
if (opts.onBootstrap === 'authenticated') {
|
|
||||||
req.flush(USER);
|
|
||||||
} else {
|
|
||||||
req.flush({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
await Promise.resolve();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
return { fixture, http, navigate };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Home', () => {
|
|
||||||
it('renders the "no session" state with a Sign in button when anonymous', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('.status-line')?.textContent).toContain('No admin session');
|
|
||||||
expect(root.querySelector('.btn--primary')?.textContent?.trim()).toBe('Sign in via Entra');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the signed-in payload (display name + tenant + oid) when authenticated', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'authenticated' });
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('.status-line--ok')?.textContent).toContain('Admin Smith');
|
|
||||||
const dds = Array.from(root.querySelectorAll<HTMLElement>('.session-detail dd')).map((el) =>
|
|
||||||
el.textContent?.trim(),
|
|
||||||
);
|
|
||||||
expect(dds).toContain('admin@apf.example');
|
|
||||||
expect(dds).toContain('tenant-1');
|
|
||||||
expect(dds).toContain('admin-oid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigates to the admin login URL on Sign in click', async () => {
|
|
||||||
const { fixture, navigate } = await setup({ onBootstrap: 'anonymous' });
|
|
||||||
(fixture.nativeElement as HTMLElement)
|
|
||||||
.querySelector<HTMLButtonElement>('.btn--primary')
|
|
||||||
?.click();
|
|
||||||
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/admin/auth/login`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists the roadmap items (audit log, CMS, menu, user list)', async () => {
|
|
||||||
const { fixture } = await setup({ onBootstrap: 'anonymous' });
|
|
||||||
const items = Array.from(
|
|
||||||
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>('.roadmap-list li'),
|
|
||||||
).map((li) => li.textContent ?? '');
|
|
||||||
expect(items.some((t) => t.includes('Audit log viewer'))).toBe(true);
|
|
||||||
expect(items.some((t) => t.includes('CMS pages'))).toBe(true);
|
|
||||||
expect(items.some((t) => t.includes('Menu management'))).toBe(true);
|
|
||||||
expect(items.some((t) => t.includes('User list'))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +1,16 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { AuthService } from 'feature-auth';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin landing page. v1 renders a self-test panel for the
|
* Smoke-test landing page for portal-admin.
|
||||||
* admin-portal auth chain: status from `AuthService` (signed-in
|
|
||||||
* payload + roles, or anonymous), a "Sign in" button when no admin
|
|
||||||
* session is present, and the next-up roadmap from ADR-0020 §"v1
|
|
||||||
* scope".
|
|
||||||
*
|
*
|
||||||
* Functional modules (CMS, menu management, user list, audit log
|
* v1 ships a placeholder. The real admin shell (header + sidebar +
|
||||||
* viewer) replace this page progressively. The audit-log viewer is
|
* footer with the "Admin" badge, per ADR-0020) lands in a follow-up
|
||||||
* next per the chantier sequence.
|
* PR together with the first functional module (CMS pages, menu
|
||||||
|
* management, user list, or audit log viewer).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrl: './home.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class Home {
|
export class Home {}
|
||||||
private readonly auth = inject(AuthService);
|
|
||||||
|
|
||||||
protected readonly state = this.auth.state;
|
|
||||||
protected readonly currentUser = this.auth.currentUser;
|
|
||||||
protected readonly isLoading = this.auth.isLoading;
|
|
||||||
|
|
||||||
protected signIn(): void {
|
|
||||||
this.auth.login();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
@if (user(); as currentUser) {
|
|
||||||
<section class="profile">
|
|
||||||
<header class="profile-header">
|
|
||||||
<h1 class="title">My profile</h1>
|
|
||||||
<p class="intro">
|
|
||||||
Identity served by the BFF from the active admin session. Read-only — role assignments are
|
|
||||||
managed in the Entra Admin Center.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<article class="card" aria-labelledby="identity-heading">
|
|
||||||
<h2 id="identity-heading" class="card-title">Identity</h2>
|
|
||||||
<dl class="grid">
|
|
||||||
<div>
|
|
||||||
<dt>Display name</dt>
|
|
||||||
<dd data-testid="profile-displayname">{{ currentUser.displayName }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Username</dt>
|
|
||||||
<dd data-testid="profile-username">{{ currentUser.username }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Entra object id</dt>
|
|
||||||
<dd class="mono" data-testid="profile-oid">{{ currentUser.oid }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Tenant id</dt>
|
|
||||||
<dd class="mono" data-testid="profile-tid">{{ currentUser.tid }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
@if (currentUser.roles && currentUser.roles.length > 0) {
|
|
||||||
<article class="card" aria-labelledby="roles-heading">
|
|
||||||
<h2 id="roles-heading" class="card-title">App roles</h2>
|
|
||||||
<p class="card-intro">
|
|
||||||
Roles assigned on the BFF's Entra app registration. Drives access to
|
|
||||||
<code>/api/admin/*</code> through <code>@RequireAdmin</code>.
|
|
||||||
</p>
|
|
||||||
<ul class="roles" data-testid="profile-roles">
|
|
||||||
@for (role of currentUser.roles; track role) {
|
|
||||||
<li class="role-chip">{{ role }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
.profile {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .intro {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .card {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .card-title {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-intro {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #4b5563;
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0.0625rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .card-intro {
|
|
||||||
color: #d1d5db;
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.875rem 1.25rem;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd.mono {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #374151;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .grid {
|
|
||||||
dt {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
color: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd.mono {
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.roles {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: rgba(29, 78, 216, 0.1);
|
|
||||||
color: var(--color-brand-primary-700, #1e40af);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .role-chip {
|
|
||||||
background-color: rgba(96, 165, 250, 0.16);
|
|
||||||
color: #bfdbfe;
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import {
|
|
||||||
AUTH_BFF_BASE_URL,
|
|
||||||
AUTH_NAVIGATOR,
|
|
||||||
AUTH_PATH_PREFIX,
|
|
||||||
type CurrentUser,
|
|
||||||
} from 'feature-auth';
|
|
||||||
import { ProfilePage } from './profile';
|
|
||||||
|
|
||||||
const BFF_BASE = 'http://bff.test/api';
|
|
||||||
const ADMIN_ME_URL = `${BFF_BASE}/admin/auth/me`;
|
|
||||||
|
|
||||||
const USER: CurrentUser = {
|
|
||||||
oid: 'admin-oid-abc',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin Smith',
|
|
||||||
roles: ['Portal.Admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function setup(user: CurrentUser | null) {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [ProfilePage],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
provideHttpClient(),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
|
||||||
{ provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' },
|
|
||||||
{ provide: AUTH_NAVIGATOR, useValue: vi.fn() },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const fixture = TestBed.createComponent(ProfilePage);
|
|
||||||
const http = TestBed.inject(HttpTestingController);
|
|
||||||
const req = http.expectOne(ADMIN_ME_URL);
|
|
||||||
if (user) {
|
|
||||||
req.flush(user);
|
|
||||||
} else {
|
|
||||||
req.flush({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
await Promise.resolve();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
return { fixture, http };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ProfilePage (portal-admin)', () => {
|
|
||||||
beforeEach(() => TestBed.resetTestingModule());
|
|
||||||
|
|
||||||
it('renders the identity card with displayName, username, oid, tid', async () => {
|
|
||||||
const { fixture } = await setup(USER);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('[data-testid="profile-displayname"]')?.textContent?.trim()).toBe(
|
|
||||||
'Admin Smith',
|
|
||||||
);
|
|
||||||
expect(root.querySelector('[data-testid="profile-username"]')?.textContent?.trim()).toBe(
|
|
||||||
'admin@apf.example',
|
|
||||||
);
|
|
||||||
expect(root.querySelector('[data-testid="profile-oid"]')?.textContent?.trim()).toBe(
|
|
||||||
'admin-oid-abc',
|
|
||||||
);
|
|
||||||
expect(root.querySelector('[data-testid="profile-tid"]')?.textContent?.trim()).toBe('tenant-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the App roles card with one chip per role when present', async () => {
|
|
||||||
const { fixture } = await setup({ ...USER, roles: ['Portal.Admin', 'Portal.Auditor'] });
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const chips = Array.from(root.querySelectorAll('[data-testid="profile-roles"] .role-chip'));
|
|
||||||
expect(chips.map((el) => el.textContent?.trim())).toEqual(['Portal.Admin', 'Portal.Auditor']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the App roles card entirely when the session carries no roles', async () => {
|
|
||||||
const { fixture } = await setup({ ...USER, roles: [] });
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('[data-testid="profile-roles"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing when the user is anonymous (template @if guard)', async () => {
|
|
||||||
const { fixture } = await setup(null);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('.profile')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|
||||||
import { AuthService } from 'feature-auth';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin-portal profile page. Lazy-loaded at `/profile`, gated by
|
|
||||||
* `authGuard` so `AuthService.currentUser()` is guaranteed non-null
|
|
||||||
* by the time the template runs.
|
|
||||||
*
|
|
||||||
* Carries more identity surface than the `portal-shell` counterpart
|
|
||||||
* because the admin-side `/api/admin/auth/me` includes the `roles`
|
|
||||||
* claim (admin SPA renders role badges, drives admin-only UI). The
|
|
||||||
* user-portal page deliberately skips `roles` to stay aligned with
|
|
||||||
* ADR-0009 §"curated public view" — see `CurrentUser` for the field
|
|
||||||
* shape.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-profile-page',
|
|
||||||
templateUrl: './profile.html',
|
|
||||||
styleUrl: './profile.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class ProfilePage {
|
|
||||||
private readonly auth = inject(AuthService);
|
|
||||||
protected readonly user = this.auth.currentUser;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,86 +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 { AdminUsersService, type AdminUsersPage } from './admin-users.service';
|
|
||||||
|
|
||||||
const BFF_BASE = 'http://bff.test/api';
|
|
||||||
const USERS_URL = `${BFF_BASE}/admin/users`;
|
|
||||||
|
|
||||||
const PAGE: AdminUsersPage = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
audience: 'workforce',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
firstSeenAt: '2026-05-10T10:00:00.000Z',
|
|
||||||
lastSeenAt: '2026-05-14T08:30:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
service: TestBed.inject(AdminUsersService),
|
|
||||||
http: TestBed.inject(HttpTestingController),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdminUsersService.query', () => {
|
|
||||||
it('GETs /admin/users with the populated filter fields as URL params', async () => {
|
|
||||||
const { service, http } = setup();
|
|
||||||
const promise = firstValueFrom(
|
|
||||||
service.query({
|
|
||||||
username: 'jane',
|
|
||||||
audience: 'workforce',
|
|
||||||
limit: 25,
|
|
||||||
offset: 50,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const req = http.expectOne((r) => r.url === USERS_URL);
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
expect(req.request.params.get('username')).toBe('jane');
|
|
||||||
expect(req.request.params.get('audience')).toBe('workforce');
|
|
||||||
expect(req.request.params.get('limit')).toBe('25');
|
|
||||||
expect(req.request.params.get('offset')).toBe('50');
|
|
||||||
req.flush(PAGE);
|
|
||||||
await expect(promise).resolves.toEqual(PAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits filter fields that are empty strings', async () => {
|
|
||||||
const { service, http } = setup();
|
|
||||||
void firstValueFrom(
|
|
||||||
service.query({
|
|
||||||
username: '',
|
|
||||||
displayName: '',
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const req = http.expectOne((r) => r.url === USERS_URL);
|
|
||||||
expect(req.request.params.has('username')).toBe(false);
|
|
||||||
expect(req.request.params.has('displayName')).toBe(false);
|
|
||||||
expect(req.request.params.get('limit')).toBe('50');
|
|
||||||
req.flush(PAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits undefined filter fields', async () => {
|
|
||||||
const { service, http } = setup();
|
|
||||||
void firstValueFrom(service.query({}));
|
|
||||||
const req = http.expectOne((r) => r.url === USERS_URL);
|
|
||||||
expect(req.request.params.keys().length).toBe(0);
|
|
||||||
req.flush(PAGE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { AUTH_BFF_BASE_URL } from 'feature-auth';
|
|
||||||
import type { Observable } from 'rxjs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row of the BFF's `GET /api/admin/users` response. Mirrors
|
|
||||||
* `AdminUserDto` on the BFF side — ISO-string timestamps, no
|
|
||||||
* `actor_id_hash` (the audit-module invariant per ADR-0013 keeps
|
|
||||||
* the salted hash inside the audit module; the admin directory
|
|
||||||
* uses Entra `oid` directly).
|
|
||||||
*/
|
|
||||||
export interface AdminUser {
|
|
||||||
readonly oid: string;
|
|
||||||
readonly tid: string;
|
|
||||||
readonly audience: string;
|
|
||||||
readonly username: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly firstSeenAt: string;
|
|
||||||
readonly lastSeenAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsersPage {
|
|
||||||
readonly items: readonly AdminUser[];
|
|
||||||
readonly total: number;
|
|
||||||
readonly limit: number;
|
|
||||||
readonly offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Filter+pagination shape sent as query params. Mirrors `AdminUsersQueryDto`. */
|
|
||||||
export interface AdminUsersQuery {
|
|
||||||
readonly username?: string | undefined;
|
|
||||||
readonly displayName?: string | undefined;
|
|
||||||
readonly audience?: 'workforce' | 'customer' | undefined;
|
|
||||||
readonly lastSeenAtFrom?: string | undefined;
|
|
||||||
readonly lastSeenAtTo?: string | undefined;
|
|
||||||
readonly limit?: number | undefined;
|
|
||||||
readonly offset?: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin HttpClient wrapper around `GET /api/admin/users`. Same shape
|
|
||||||
* as `AuditEventsService` — provided in `root` because the users
|
|
||||||
* page is the only v1 consumer, no per-page DI overhead worth it.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class AdminUsersService {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
|
|
||||||
|
|
||||||
query(filters: AdminUsersQuery): Observable<AdminUsersPage> {
|
|
||||||
let params = new HttpParams();
|
|
||||||
for (const [key, value] of Object.entries(filters)) {
|
|
||||||
// Drop empty strings — Nest's ValidationPipe rejects `?foo=`
|
|
||||||
// as `foo === ''` for IsString / IsISO8601 validators.
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
params = params.set(key, String(value));
|
|
||||||
}
|
|
||||||
return this.http.get<AdminUsersPage>(`${this.bffBaseUrl}/admin/users`, { params });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<section class="users">
|
|
||||||
<header class="users-header">
|
|
||||||
<h1 class="title">Users</h1>
|
|
||||||
<p class="intro">
|
|
||||||
Read-only directory of every identity that has signed in to portal-shell or portal-admin per
|
|
||||||
<a
|
|
||||||
href="https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0020-portal-admin-app.md"
|
|
||||||
rel="noopener"
|
|
||||||
target="_blank"
|
|
||||||
>ADR-0020</a
|
|
||||||
>. Every query run on this page emits an <code>admin.users.query</code> audit row — directory
|
|
||||||
access is itself auditable.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form class="filters" (submit)="$event.preventDefault(); search()" aria-labelledby="filters-h">
|
|
||||||
<h2 id="filters-h" class="filters-heading">Filters</h2>
|
|
||||||
<div class="filters-grid">
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Username starts with</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
[ngModel]="username()"
|
|
||||||
(ngModelChange)="username.set($event)"
|
|
||||||
placeholder="jane.doe"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Display name contains</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="displayName"
|
|
||||||
[ngModel]="displayName()"
|
|
||||||
(ngModelChange)="displayName.set($event)"
|
|
||||||
placeholder="Doe"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Audience</span>
|
|
||||||
<select name="audience" [ngModel]="audience()" (ngModelChange)="audience.set($event)">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="workforce">workforce</option>
|
|
||||||
<option value="customer">customer</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Last seen from</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
name="lastSeenAtFrom"
|
|
||||||
[ngModel]="lastSeenAtFrom()"
|
|
||||||
(ngModelChange)="lastSeenAtFrom.set($event)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Last seen to</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
name="lastSeenAtTo"
|
|
||||||
[ngModel]="lastSeenAtTo()"
|
|
||||||
(ngModelChange)="lastSeenAtTo.set($event)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="filter">
|
|
||||||
<span class="filter-label">Page size</span>
|
|
||||||
<select name="limit" [ngModel]="limit()" (ngModelChange)="limit.set(+$event)">
|
|
||||||
@for (size of pageSizes; track size) {
|
|
||||||
<option [value]="size">{{ size }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="filters-actions">
|
|
||||||
<button type="submit" class="btn btn--primary" [disabled]="loading()">Apply filters</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn--secondary"
|
|
||||||
(click)="clearFilters()"
|
|
||||||
[disabled]="loading()"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
} @else if (page(); as p) {
|
|
||||||
<span class="status-line">{{ resultRange() }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (page(); as p) { @if (p.items.length === 0) {
|
|
||||||
<p class="empty">No users match the current filters.</p>
|
|
||||||
} @else {
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="users-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Display name</th>
|
|
||||||
<th scope="col">Username</th>
|
|
||||||
<th scope="col">Audience</th>
|
|
||||||
<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>
|
|
||||||
@for (user of p.items; track user.oid) {
|
|
||||||
<tr>
|
|
||||||
<td class="cell-name">{{ user.displayName }}</td>
|
|
||||||
<td class="cell-username">{{ user.username }}</td>
|
|
||||||
<td class="cell-aud">
|
|
||||||
<span class="aud-badge">{{ user.audience }}</span>
|
|
||||||
</td>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
.users {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0.0625rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--color-brand-primary-600, #2563eb);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .intro {
|
|
||||||
color: #9ca3af;
|
|
||||||
code {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: var(--color-brand-primary-300, #93c5fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .filters {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-heading {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .filter-label {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter input,
|
|
||||||
.filter select {
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: #ffffff;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .filter input,
|
|
||||||
:host-context(.dark) .filter select {
|
|
||||||
background-color: #0f172a;
|
|
||||||
border-color: #374151;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 2.25rem;
|
|
||||||
padding: 0 0.875rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-brand-primary-500, #2563eb);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--primary {
|
|
||||||
background-color: var(--color-brand-primary-600, #2563eb);
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-brand-primary-700, #1d4ed8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--secondary {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: #d1d5db;
|
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .btn--secondary:hover:not(:disabled) {
|
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
min-height: 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-line--error {
|
|
||||||
color: #b91c1c;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .status-bar {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .status-line--error {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
color: #6b7280;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px dashed #d1d5db;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .empty {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #374151;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .table-wrap {
|
|
||||||
background-color: #111827;
|
|
||||||
border-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .users-table {
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border-bottom-color: #1f2937;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background-color: #1f2937;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-username,
|
|
||||||
.cell-timestamp,
|
|
||||||
.cell-oid {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-oid {
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .cell-oid {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell-timestamp {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aud-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host-context(.dark) .aud-badge {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import {
|
|
||||||
AdminUsersService,
|
|
||||||
type AdminUsersPage,
|
|
||||||
type AdminUsersQuery,
|
|
||||||
} from './admin-users.service';
|
|
||||||
import { UsersPage } from './users';
|
|
||||||
|
|
||||||
const PAGE: AdminUsersPage = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
oid: 'jane-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
audience: 'workforce',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
firstSeenAt: '2026-05-10T10:00:00.000Z',
|
|
||||||
lastSeenAt: '2026-05-14T08:30:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
oid: 'admin-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
audience: 'workforce',
|
|
||||||
username: 'admin@apf.example',
|
|
||||||
displayName: 'Admin Smith',
|
|
||||||
firstSeenAt: '2026-05-09T08:00:00.000Z',
|
|
||||||
lastSeenAt: '2026-05-13T17:45:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 137,
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Fixture {
|
|
||||||
fixture: ReturnType<typeof TestBed.createComponent<UsersPage>>;
|
|
||||||
query: ReturnType<typeof vi.fn>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setup(opts?: { initial?: AdminUsersPage | 'error'; status?: number }): Fixture {
|
|
||||||
const initial = opts?.initial ?? PAGE;
|
|
||||||
const query = vi.fn().mockImplementation(() => {
|
|
||||||
if (initial === 'error') {
|
|
||||||
return throwError(() => ({ status: opts?.status ?? 500 }));
|
|
||||||
}
|
|
||||||
return of(initial);
|
|
||||||
});
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [UsersPage],
|
|
||||||
providers: [{ provide: AdminUsersService, useValue: { query } }, provideRouter([])],
|
|
||||||
});
|
|
||||||
const fixture = TestBed.createComponent(UsersPage);
|
|
||||||
return { fixture, query };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flush(fixture: Fixture['fixture']): Promise<void> {
|
|
||||||
fixture.detectChanges();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('UsersPage', () => {
|
|
||||||
it('runs an initial unfiltered query on init and renders the result table', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
expect(query).toHaveBeenCalledTimes(1);
|
|
||||||
expect(query).toHaveBeenCalledWith(expect.objectContaining({ limit: 50, offset: 0 }));
|
|
||||||
const rows = (fixture.nativeElement as HTMLElement).querySelectorAll<HTMLTableRowElement>(
|
|
||||||
'.users-table tbody tr',
|
|
||||||
);
|
|
||||||
expect(rows.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the result range ("1–2 of 137") in the status bar', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
expect(
|
|
||||||
(fixture.nativeElement as HTMLElement).querySelector('.status-line')?.textContent,
|
|
||||||
).toContain('1–2 of 137');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the empty state when the page has no items', async () => {
|
|
||||||
const { fixture } = setup({
|
|
||||||
initial: { items: [], total: 0, limit: 50, offset: 0 },
|
|
||||||
});
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(root.querySelector('.empty')?.textContent).toContain('No users');
|
|
||||||
expect(root.querySelector('.users-table')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a permission-aware error message on 403', async () => {
|
|
||||||
const { fixture } = setup({ initial: 'error', status: 403 });
|
|
||||||
await flush(fixture);
|
|
||||||
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
|
|
||||||
expect(status?.textContent).toContain('do not have access');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a generic error message on 5xx', async () => {
|
|
||||||
const { fixture } = setup({ initial: 'error', status: 500 });
|
|
||||||
await flush(fixture);
|
|
||||||
const status = (fixture.nativeElement as HTMLElement).querySelector('.status-line--error');
|
|
||||||
expect(status?.textContent).toContain('Could not load');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the populated filter fields to the service on Apply', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const component = fixture.componentInstance;
|
|
||||||
(component as unknown as { username: { set: (v: string) => void } }).username.set('jane');
|
|
||||||
(component as unknown as { audience: { set: (v: string) => void } }).audience.set('workforce');
|
|
||||||
await component.search();
|
|
||||||
await flush(fixture);
|
|
||||||
expect(query).toHaveBeenCalledTimes(2);
|
|
||||||
const lastCall = query.mock.calls[1]?.[0] as AdminUsersQuery;
|
|
||||||
expect(lastCall.username).toBe('jane');
|
|
||||||
expect(lastCall.audience).toBe('workforce');
|
|
||||||
expect(lastCall.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets the offset to 0 on each Apply', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const component = fixture.componentInstance;
|
|
||||||
await component.next();
|
|
||||||
await flush(fixture);
|
|
||||||
expect((query.mock.calls[1]?.[0] as AdminUsersQuery).offset).toBe(50);
|
|
||||||
await component.search();
|
|
||||||
await flush(fixture);
|
|
||||||
expect((query.mock.calls[2]?.[0] as AdminUsersQuery).offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clearFilters empties every filter signal and re-queries from offset 0', async () => {
|
|
||||||
const { fixture, query } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const component = fixture.componentInstance;
|
|
||||||
(component as unknown as { username: { set: (v: string) => void } }).username.set('x');
|
|
||||||
(component as unknown as { displayName: { set: (v: string) => void } }).displayName.set('y');
|
|
||||||
await component.clearFilters();
|
|
||||||
await flush(fixture);
|
|
||||||
const lastCall = query.mock.calls[1]?.[0] as AdminUsersQuery;
|
|
||||||
expect(lastCall.username).toBeUndefined();
|
|
||||||
expect(lastCall.displayName).toBeUndefined();
|
|
||||||
expect(lastCall.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Next is disabled when the loaded page covers the total', async () => {
|
|
||||||
const { fixture } = setup({
|
|
||||||
initial: { items: PAGE.items, total: 2, limit: 50, offset: 0 },
|
|
||||||
});
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const nextBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
|
|
||||||
(b) => b.textContent?.trim() === 'Next',
|
|
||||||
);
|
|
||||||
expect(nextBtn?.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Previous is disabled at offset 0', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const prevBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('.pagination .btn')).find(
|
|
||||||
(b) => b.textContent?.trim() === 'Previous',
|
|
||||||
);
|
|
||||||
expect(prevBtn?.disabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the displayName + username + audience badge per row', async () => {
|
|
||||||
const { fixture } = setup();
|
|
||||||
await flush(fixture);
|
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
|
||||||
const rows = root.querySelectorAll<HTMLTableRowElement>('.users-table tbody tr');
|
|
||||||
expect(rows[0]?.querySelector('.cell-name')?.textContent?.trim()).toBe('Jane Doe');
|
|
||||||
expect(rows[0]?.querySelector('.cell-username')?.textContent?.trim()).toBe(
|
|
||||||
'jane.doe@apf.example',
|
|
||||||
);
|
|
||||||
expect(rows[0]?.querySelector('.aud-badge')?.textContent?.trim()).toBe('workforce');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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,
|
|
||||||
type AdminUsersPage,
|
|
||||||
type AdminUsersQuery,
|
|
||||||
} from './admin-users.service';
|
|
||||||
|
|
||||||
/** Page-size options. Matches the BFF's `DEFAULT_LIMIT` (50) and `MAX_LIMIT` (200). */
|
|
||||||
const PAGE_SIZES = [25, 50, 100, 200] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User directory viewer per ADR-0020's v1 admin catalogue. Consumes
|
|
||||||
* `GET /api/admin/users` from the BFF (PR #141) and renders:
|
|
||||||
*
|
|
||||||
* - Filter row: username prefix, displayName contains,
|
|
||||||
* audience, last-seen-at range, page-size selector.
|
|
||||||
* - Paginated table with `oid`, `displayName`, `username`,
|
|
||||||
* `audience`, `firstSeenAt`, `lastSeenAt` columns.
|
|
||||||
* - Pagination controls + loading / empty / error states.
|
|
||||||
*
|
|
||||||
* Mirrors the `/audit` page (PR #136) in shape and signal layout
|
|
||||||
* so a future contributor lands familiar code regardless of which
|
|
||||||
* admin module they open first.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-users-page',
|
|
||||||
imports: [FormsModule, RouterLink],
|
|
||||||
templateUrl: './users.html',
|
|
||||||
styleUrl: './users.scss',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
|
||||||
export class UsersPage {
|
|
||||||
private readonly service = inject(AdminUsersService);
|
|
||||||
|
|
||||||
protected readonly pageSizes = PAGE_SIZES;
|
|
||||||
|
|
||||||
protected readonly username = signal('');
|
|
||||||
protected readonly displayName = signal('');
|
|
||||||
protected readonly audience = signal<'' | 'workforce' | 'customer'>('');
|
|
||||||
protected readonly lastSeenAtFrom = signal('');
|
|
||||||
protected readonly lastSeenAtTo = signal('');
|
|
||||||
|
|
||||||
protected readonly limit = signal<number>(50);
|
|
||||||
protected readonly offset = signal<number>(0);
|
|
||||||
|
|
||||||
protected readonly page = signal<AdminUsersPage | null>(null);
|
|
||||||
protected readonly loading = signal(false);
|
|
||||||
protected readonly error = signal<string | null>(null);
|
|
||||||
|
|
||||||
protected readonly hasNextPage = computed(() => {
|
|
||||||
const p = this.page();
|
|
||||||
if (p === null) return false;
|
|
||||||
return p.offset + p.items.length < p.total;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly hasPreviousPage = computed(() => {
|
|
||||||
const p = this.page();
|
|
||||||
if (p === null) return false;
|
|
||||||
return p.offset > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected readonly resultRange = computed(() => {
|
|
||||||
const p = this.page();
|
|
||||||
if (p === null) return '';
|
|
||||||
if (p.items.length === 0) return p.total === 0 ? 'No users' : `0 of ${p.total}`;
|
|
||||||
const first = p.offset + 1;
|
|
||||||
const last = p.offset + p.items.length;
|
|
||||||
return `${first}–${last} of ${p.total}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
async search(): Promise<void> {
|
|
||||||
this.offset.set(0);
|
|
||||||
await this.fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async next(): Promise<void> {
|
|
||||||
if (!this.hasNextPage()) return;
|
|
||||||
this.offset.update((o) => o + this.limit());
|
|
||||||
await this.fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async previous(): Promise<void> {
|
|
||||||
if (!this.hasPreviousPage()) return;
|
|
||||||
this.offset.update((o) => Math.max(0, o - this.limit()));
|
|
||||||
await this.fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearFilters(): Promise<void> {
|
|
||||||
this.username.set('');
|
|
||||||
this.displayName.set('');
|
|
||||||
this.audience.set('');
|
|
||||||
this.lastSeenAtFrom.set('');
|
|
||||||
this.lastSeenAtTo.set('');
|
|
||||||
this.offset.set(0);
|
|
||||||
await this.fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected formatTimestamp(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetch(): Promise<void> {
|
|
||||||
this.loading.set(true);
|
|
||||||
this.error.set(null);
|
|
||||||
try {
|
|
||||||
const page = await firstValueFrom(this.service.query(this.buildFilters()));
|
|
||||||
this.page.set(page);
|
|
||||||
} catch (err) {
|
|
||||||
const status = errorStatus(err);
|
|
||||||
this.error.set(
|
|
||||||
status === 403
|
|
||||||
? 'You do not have access to the user directory on this session.'
|
|
||||||
: 'Could not load the user directory. Try again in a moment.',
|
|
||||||
);
|
|
||||||
this.page.set(null);
|
|
||||||
} finally {
|
|
||||||
this.loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFilters(): AdminUsersQuery {
|
|
||||||
return {
|
|
||||||
username: this.username().trim() || undefined,
|
|
||||||
displayName: this.displayName().trim() || undefined,
|
|
||||||
audience: this.audience() || undefined,
|
|
||||||
lastSeenAtFrom: this.lastSeenAtFrom() ? toIso(this.lastSeenAtFrom()) : undefined,
|
|
||||||
lastSeenAtTo: this.lastSeenAtTo() ? toIso(this.lastSeenAtTo()) : undefined,
|
|
||||||
limit: this.limit(),
|
|
||||||
offset: this.offset(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
void this.fetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIso(localValue: string): string {
|
|
||||||
// `<input type="datetime-local">` emits `YYYY-MM-DDTHH:mm` (no
|
|
||||||
// timezone). Treat as the user's local time and convert to ISO
|
|
||||||
// 8601 so the BFF's IsISO8601 validator accepts it.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-environment configuration for `portal-admin`, per ADR-0018.
|
|
||||||
*
|
|
||||||
* Holds the dev defaults; per-environment siblings
|
|
||||||
* (`environment.staging.ts`, `environment.prod.ts`) land alongside
|
|
||||||
* once those targets exist, and `project.json` declares a
|
|
||||||
* `fileReplacements` entry to swap at build time.
|
|
||||||
*
|
|
||||||
* Shape mirrors `apps/portal-shell/src/environments/environment.ts`
|
|
||||||
* intentionally: any change here that affects the consumer contract
|
|
||||||
* with `feature-auth` must be made in both files (same env vars on
|
|
||||||
* the lib side, same BFF base URL pattern).
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
* `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',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the BFF's CSRF cookie. v1 reuses `portal_csrf`
|
|
||||||
* (shared between surfaces; see PR #129's notes on
|
|
||||||
* `__Host-portal_csrf` / `portal_csrf`). A future hardening
|
|
||||||
* could split this into `portal_admin_csrf` when both portals
|
|
||||||
* are open simultaneously becomes a regular pattern.
|
|
||||||
*/
|
|
||||||
bffCsrfCookieName: 'portal_csrf',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OTLP/HTTP traces endpoint — same OTel collector as portal-shell.
|
|
||||||
*/
|
|
||||||
otlpEndpoint: 'http://localhost:4318/v1/traces',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Origin of the sibling `portal-shell` SPA — drives the "Open
|
|
||||||
* Portal Shell" entry in the admin user menu. Symmetric with
|
|
||||||
* `portal-shell`'s `adminAppUrl`; per ADR-0020 the two SPAs live
|
|
||||||
* on distinct origins, so this is a raw cross-origin `<a href>`,
|
|
||||||
* 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',
|
|
||||||
};
|
|
||||||
@@ -13,22 +13,6 @@
|
|||||||
<source>APF Portal Admin</source>
|
<source>APF Portal Admin</source>
|
||||||
<target>Administration APF Portal</target>
|
<target>Administration APF Portal</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="route.audit.title" datatype="html">
|
|
||||||
<source>Audit log — APF Portal Admin</source>
|
|
||||||
<target>Journal d’audit — Administration APF Portal</target>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="route.users.title" datatype="html">
|
|
||||||
<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>
|
|
||||||
</trans-unit>
|
|
||||||
|
|
||||||
<!-- page.home -->
|
<!-- page.home -->
|
||||||
<trans-unit id="page.home.title" datatype="html">
|
<trans-unit id="page.home.title" datatype="html">
|
||||||
|
|||||||
@@ -16,18 +16,3 @@
|
|||||||
|
|
||||||
/* Brand palette tokens — shared with portal-shell. */
|
/* Brand palette tokens — shared with portal-shell. */
|
||||||
@import '../../../libs/shared/tokens/src/brand-tokens.css';
|
@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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,16 +5,5 @@
|
|||||||
"types": ["@angular/localize"]
|
"types": ["@angular/localize"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../../libs/shared/charts/tsconfig.lib.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../../libs/shared/ui/tsconfig.lib.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../../libs/feature/auth/tsconfig.lib.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,23 +60,6 @@ ENTRA_CLIENT_SECRET=replace_with_real_value
|
|||||||
# User portal — `/api/auth/callback` is the OIDC return URL; the
|
# User portal — `/api/auth/callback` is the OIDC return URL; the
|
||||||
# post-logout URL is where Entra sends the browser after RP-initiated
|
# post-logout URL is where Entra sends the browser after RP-initiated
|
||||||
# logout (typically the SPA landing page).
|
# 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_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||||
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
|
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
|
||||||
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
|
# 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_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
|
||||||
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
|
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
|
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
|
||||||
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
||||||
# verifier between the /auth/login redirect and the /auth/callback
|
# verifier between the /auth/login redirect and the /auth/callback
|
||||||
@@ -143,34 +104,6 @@ REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
|
|||||||
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
||||||
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
|
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
|
||||||
|
|
||||||
# OBO downstream-token cache encryption (per ADR-0014 §"Token cache
|
|
||||||
# (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream
|
|
||||||
# tokens cached in Redis under `obo:{actor_id_hash}:{resource}`.
|
|
||||||
# **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a
|
|
||||||
# leak of one does not cascade into the other. The boot validator
|
|
||||||
# refuses an identical value. Mandatory at boot.
|
|
||||||
#
|
|
||||||
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
|
||||||
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
|
|
||||||
|
|
||||||
# BFF JWKS signing material (per ADR-0014 §"Service strategy"). The
|
|
||||||
# BFF mints short-lived `X-User-Assertion` JWTs to propagate user
|
|
||||||
# identity to non-Entra downstreams; downstreams verify the signature
|
|
||||||
# against `/.well-known/jwks.json`. Both values are mandatory at boot.
|
|
||||||
#
|
|
||||||
# Generate an RSA private key:
|
|
||||||
# mkdir -p apps/portal-bff/.secrets && \
|
|
||||||
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
|
|
||||||
# -out apps/portal-bff/.secrets/jwks.pem
|
|
||||||
#
|
|
||||||
# RSA-2048 is the minimum the validator accepts; 3072 is the
|
|
||||||
# default recommendation. EC P-256 / P-384 also accepted.
|
|
||||||
BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem
|
|
||||||
# Stable key id published in the JWKS + emitted in the JWT `kid`
|
|
||||||
# header. URL-safe charset only ([A-Za-z0-9_-], 4–128 chars). Bump
|
|
||||||
# this when rotating to a new key.
|
|
||||||
BFF_JWKS_KID=bff-2026-05
|
|
||||||
|
|
||||||
# Session timeouts (per ADR-0010). Both optional with sensible
|
# Session timeouts (per ADR-0010). Both optional with sensible
|
||||||
# defaults; override only when staging / prod policy diverges.
|
# defaults; override only when staging / prod policy diverges.
|
||||||
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
|
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
|
||||||
@@ -240,21 +173,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
|
|||||||
# AUDIT_RETENTION_DAYS (default 365)
|
# AUDIT_RETENTION_DAYS (default 365)
|
||||||
#
|
#
|
||||||
# Downstream API access (ADR-0014):
|
# Downstream API access (ADR-0014):
|
||||||
# OBO_CACHE_ENCRYPTION_KEY — wired
|
# OBO_CACHE_ENCRYPTION_KEY (32-byte base64, distinct from SESSION_ENCRYPTION_KEY)
|
||||||
# BFF_JWKS_PRIVATE_KEY_PATH — wired
|
# BFF_JWKS_PRIVATE_KEY_PATH
|
||||||
# BFF_JWKS_KID — wired
|
# BFF_JWKS_KID
|
||||||
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration)
|
# <SERVICE>_API_BASE_URL (per integrated downstream)
|
||||||
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)
|
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000)
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -6,19 +6,5 @@ module.exports = {
|
|||||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
// Several deps ship ESM-only; without an explicit transform
|
|
||||||
// exception ts-jest skips node_modules and Jest fails parsing the
|
|
||||||
// import statements. Listed by name rather than a broad pattern
|
|
||||||
// so each new ESM-only dep is a conscious addition.
|
|
||||||
//
|
|
||||||
// The pattern walks pnpm's deep layout: any path under
|
|
||||||
// `/node_modules/` is ignored UNLESS the path also contains one
|
|
||||||
// of the listed package fragments — catches both the hoisted
|
|
||||||
// symlink at `node_modules/<pkg>/...` and the pnpm-internal real
|
|
||||||
// path at `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/...`.
|
|
||||||
//
|
|
||||||
// jose — JOSE primitives (signed-assertion strategy, PR #138).
|
|
||||||
// @scalar/ — Scalar API reference UI + transitive ESM bits.
|
|
||||||
transformIgnorePatterns: ['/node_modules/(?!.*(jose|@scalar/))'],
|
|
||||||
coverageDirectory: '../../coverage/apps/portal-bff',
|
coverageDirectory: '../../coverage/apps/portal-bff',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
-- Users directory (per ADR-0020 §"v1 scope — User list").
|
|
||||||
--
|
|
||||||
-- Persistent ledger of identities the BFF has seen sign in to either
|
|
||||||
-- portal-shell or portal-admin. Upserted by `UserDirectoryService`
|
|
||||||
-- at every sign-in. Read by the future `GET /api/admin/users` admin
|
|
||||||
-- endpoint. Entra ID is the source of truth for identity; this
|
|
||||||
-- table is the BFF's local cache so the admin UI does not have to
|
|
||||||
-- re-query the IdP at every page render.
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "users" (
|
|
||||||
"oid" TEXT NOT NULL,
|
|
||||||
"tid" TEXT NOT NULL,
|
|
||||||
"audience" TEXT NOT NULL,
|
|
||||||
"username" TEXT NOT NULL,
|
|
||||||
"display_name" TEXT NOT NULL,
|
|
||||||
"first_seen_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"last_seen_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "users_pkey" PRIMARY KEY ("oid")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- DESC index supports the default admin sort ("most recently active first")
|
|
||||||
-- without a server-side sort.
|
|
||||||
CREATE INDEX "users_last_seen_at_idx" ON "users"("last_seen_at" DESC);
|
|
||||||
|
|
||||||
-- Plain (default ASC) index supports prefix matching on the
|
|
||||||
-- admin-side username filter.
|
|
||||||
CREATE INDEX "users_username_idx" ON "users"("username");
|
|
||||||
@@ -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');
|
|
||||||
-19
@@ -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";
|
|
||||||
-84
@@ -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;
|
|
||||||
@@ -49,263 +49,6 @@ enum AuditOutcome {
|
|||||||
@@schema("audit")
|
@@schema("audit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// User directory (per ADR-0020 §"v1 scope — User list")
|
|
||||||
// ============================================================
|
|
||||||
//
|
|
||||||
// Persistent ledger of every identity that has signed in to either
|
|
||||||
// portal-shell or portal-admin. Upserted at sign-in by
|
|
||||||
// `UserDirectoryService.recordSignIn` (called from
|
|
||||||
// `SessionEstablisher.establish`), read by the future
|
|
||||||
// `GET /api/admin/users` endpoint per ADR-0020 §"User list
|
|
||||||
// (read-only)".
|
|
||||||
//
|
|
||||||
// **Not the source of truth for identity.** Entra ID is. This table
|
|
||||||
// is a cache the BFF maintains so the admin UI can list "everyone
|
|
||||||
// who's ever signed in" without re-querying the directory at
|
|
||||||
// every render. Per-tenant `oid` is the join key on the audit side
|
|
||||||
// (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
|
|
||||||
// admin browsing the user list explicitly wants display names and
|
|
||||||
// usernames. The trust boundary is the admin role gate
|
|
||||||
// (ADR-0020 §"Auth — `admin` role claim").
|
|
||||||
|
|
||||||
model UserDirectoryEntry {
|
|
||||||
// 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
|
|
||||||
// assumes single workforce tenant; cross-tenant collisions
|
|
||||||
// become a separate ADR when we onboard the second one.
|
|
||||||
oid String @id
|
|
||||||
tid String
|
|
||||||
audience String
|
|
||||||
username String
|
|
||||||
displayName String @map("display_name")
|
|
||||||
// `first_seen_at` is set once at first sign-in and never
|
|
||||||
// updated thereafter. Lets the admin list "users since
|
|
||||||
// <date>" without joining anything.
|
|
||||||
firstSeenAt DateTime @default(now()) @map("first_seen_at") @db.Timestamptz(6)
|
|
||||||
// `last_seen_at` is set every time the upsert fires (one upsert
|
|
||||||
// per sign-in), so "most recently active" can be computed
|
|
||||||
// without scanning audit.events.
|
|
||||||
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
|
|
||||||
|
|
||||||
@@map("user_directory_entries")
|
|
||||||
@@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 {
|
model AuditEvent {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|||||||
@@ -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 { AdminAuditController } from './admin-audit.controller';
|
||||||
import type { AdminAuditQueryDto } from './audit-query.dto';
|
import type { AdminAuditQueryDto } from './audit-query.dto';
|
||||||
import type { AuditReader, AdminAuditPage } from './audit-reader.service';
|
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';
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
|
|
||||||
const PAGE: AdminAuditPage = {
|
const PAGE: AdminAuditPage = {
|
||||||
@@ -29,30 +27,18 @@ function makeReq(user?: { oid: string }): Request {
|
|||||||
return { session: user !== undefined ? { user } : {} } as unknown as 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() {
|
function makeFixture() {
|
||||||
const auditReader = {
|
const auditReader = {
|
||||||
findEvents: jest.fn().mockResolvedValue(PAGE),
|
findEvents: jest.fn().mockResolvedValue(PAGE),
|
||||||
};
|
};
|
||||||
const auditStatsReader = {
|
|
||||||
getStats: jest.fn().mockResolvedValue(STATS),
|
|
||||||
};
|
|
||||||
const auditWriter = {
|
const auditWriter = {
|
||||||
adminAuditQuery: jest.fn().mockResolvedValue(undefined),
|
adminAuditQuery: jest.fn().mockResolvedValue(undefined),
|
||||||
adminAuditStatsQuery: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
};
|
||||||
const controller = new AdminAuditController(
|
const controller = new AdminAuditController(
|
||||||
auditReader as unknown as AuditReader,
|
auditReader as unknown as AuditReader,
|
||||||
auditStatsReader as unknown as AuditStatsReader,
|
|
||||||
auditWriter as unknown as AuditWriter,
|
auditWriter as unknown as AuditWriter,
|
||||||
);
|
);
|
||||||
return { controller, auditReader, auditStatsReader, auditWriter };
|
return { controller, auditReader, auditWriter };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AdminAuditController.list', () => {
|
describe('AdminAuditController.list', () => {
|
||||||
@@ -112,51 +98,3 @@ describe('AdminAuditController.list', () => {
|
|||||||
).rejects.toThrow('audit_writer denied');
|
).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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { AdminAuditQueryDto } from './audit-query.dto';
|
import { AdminAuditQueryDto } from './audit-query.dto';
|
||||||
import { AuditReader, type AdminAuditPage } from './audit-reader.service';
|
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';
|
import { RequireAdmin } from './require-admin.decorator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,20 +27,14 @@ import { RequireAdmin } from './require-admin.decorator';
|
|||||||
* with a tighter freshness here without touching this code's
|
* with a tighter freshness here without touching this code's
|
||||||
* shape — that's exactly why the decorator was designed-in.
|
* shape — that's exactly why the decorator was designed-in.
|
||||||
*/
|
*/
|
||||||
@ApiTags('admin (audit log)')
|
|
||||||
@ApiCookieAuth('portal_admin_session')
|
|
||||||
@Controller('admin/audit')
|
@Controller('admin/audit')
|
||||||
@RequireAdmin()
|
@RequireAdmin()
|
||||||
export class AdminAuditController {
|
export class AdminAuditController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auditReader: AuditReader,
|
private readonly auditReader: AuditReader,
|
||||||
private readonly auditStats: AuditStatsReader,
|
|
||||||
private readonly audit: AuditWriter,
|
private readonly audit: AuditWriter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Paginated audit-log query — emits `admin.audit.query` on every call',
|
|
||||||
})
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
|
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
|
||||||
const page = await this.auditReader.findEvents(filters);
|
const page = await this.auditReader.findEvents(filters);
|
||||||
@@ -64,38 +55,4 @@ export class AdminAuditController {
|
|||||||
|
|
||||||
return page;
|
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,5 +1,4 @@
|
|||||||
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
@@ -38,7 +37,6 @@ import { adminSessionCookieName } from '../session/admin-session-cookie';
|
|||||||
* the session that those guards check against. Sub-controllers
|
* the session that those guards check against. Sub-controllers
|
||||||
* (admin business routes) layer the guards on top.
|
* (admin business routes) layer the guards on top.
|
||||||
*/
|
*/
|
||||||
@ApiTags('auth (admin portal)')
|
|
||||||
@Controller('admin/auth')
|
@Controller('admin/auth')
|
||||||
export class AdminAuthController {
|
export class AdminAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -49,7 +47,6 @@ export class AdminAuthController {
|
|||||||
private readonly sessionEstablisher: SessionEstablisher,
|
private readonly sessionEstablisher: SessionEstablisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Start the admin OIDC Auth Code + PKCE flow (302 to Entra)' })
|
|
||||||
@Get('login')
|
@Get('login')
|
||||||
async login(@Res() res: Response): Promise<void> {
|
async login(@Res() res: Response): Promise<void> {
|
||||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
||||||
@@ -59,9 +56,6 @@ export class AdminAuthController {
|
|||||||
res.redirect(302, authUrl);
|
res.redirect(302, authUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Entra callback for the admin surface — establishes the admin session',
|
|
||||||
})
|
|
||||||
@Get('callback')
|
@Get('callback')
|
||||||
async callback(
|
async callback(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@@ -133,8 +127,6 @@ export class AdminAuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Current admin session payload (includes `roles`)' })
|
|
||||||
@ApiCookieAuth('portal_admin_session')
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request, @Res() res: Response): void {
|
me(@Req() req: Request, @Res() res: Response): void {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -156,8 +148,6 @@ export class AdminAuthController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({ summary: 'RP-initiated admin logout — destroys admin session' })
|
|
||||||
@ApiCookieAuth('portal_admin_session')
|
|
||||||
@Get('logout')
|
@Get('logout')
|
||||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import type { Principal } from 'shared-auth';
|
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard';
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ interface SessionStub {
|
|||||||
amr: readonly string[];
|
amr: readonly string[];
|
||||||
roles: readonly string[];
|
roles: readonly string[];
|
||||||
};
|
};
|
||||||
principal?: Principal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRequest(opts: {
|
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', () => {
|
describe('AdminRoleGuard', () => {
|
||||||
it('throws 401 when the request has no session at all', async () => {
|
it('throws 401 when the request has no session at all', async () => {
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
@@ -65,7 +47,7 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
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 audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(makeRequest({ session: {} }));
|
const ctx = makeContext(makeRequest({ session: {} }));
|
||||||
@@ -73,12 +55,21 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).not.toHaveBeenCalled();
|
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 audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
makeRequest({
|
makeRequest({
|
||||||
session: { principal: principalWithPrivileges([]) },
|
session: {
|
||||||
|
user: {
|
||||||
|
oid: 'user-oid',
|
||||||
|
tid: 't',
|
||||||
|
username: 'jane@example',
|
||||||
|
displayName: 'Jane',
|
||||||
|
amr: ['pwd'],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
originalUrl: '/api/admin/me',
|
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 audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
makeRequest({
|
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',
|
method: 'POST',
|
||||||
originalUrl: '/api/admin/cms/pages',
|
originalUrl: '/api/admin/cms/pages',
|
||||||
}),
|
}),
|
||||||
@@ -105,27 +105,45 @@ describe('AdminRoleGuard', () => {
|
|||||||
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
expect(audit.adminAccessDenied).toHaveBeenCalledWith({
|
||||||
actor: { oid: 'user-oid' },
|
actor: { oid: 'user-oid' },
|
||||||
attemptedRoute: 'POST /api/admin/cms/pages',
|
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 () => {
|
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([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 () => {
|
|
||||||
const audit = makeAuditStub();
|
const audit = makeAuditStub();
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
||||||
const ctx = makeContext(
|
const ctx = makeContext(
|
||||||
makeRequest({
|
makeRequest({
|
||||||
session: {
|
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')),
|
adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')),
|
||||||
};
|
};
|
||||||
const guard = new AdminRoleGuard(audit as unknown as AuditWriter);
|
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
|
// The 403 path goes through audit first; if audit throws, the
|
||||||
// guard surfaces the underlying error rather than the
|
// guard surfaces the underlying error rather than the
|
||||||
// ForbiddenException — the caller sees a 500 and the audit
|
// ForbiddenException — the caller sees a 500 and the audit
|
||||||
// invariant is preserved.
|
// invariant is preserved.
|
||||||
await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied');
|
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: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,25 +7,19 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { readSessionPrincipal } from '../auth/principal-extractor';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Entra app role that gates the entire `/api/admin/*` surface
|
* Single Entra app role that gates the entire `/api/admin/*` surface
|
||||||
* per ADR-0020 §"Auth — `Portal.Admin` role claim". The value matches
|
* per ADR-0020 §"Auth — `admin` role claim". The value matches the
|
||||||
* the `value` field declared on the app role in the Entra app
|
* `value` field declared on the app role in the Entra app
|
||||||
* registration manifest. Defined as a constant so spec assertions
|
* registration manifest. Defined as a constant so spec assertions
|
||||||
* can refer to the same source of truth.
|
* can refer to the same source of truth.
|
||||||
*/
|
*/
|
||||||
export const ADMIN_ROLE = 'Portal.Admin';
|
export const ADMIN_ROLE = 'admin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
|
* `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
|
* Contract
|
||||||
* --------
|
* --------
|
||||||
* - **No session → 401.** The user is not authenticated at all; the
|
* - **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
|
* unauthenticated 401 is normal traffic the absolute-timeout
|
||||||
* middleware would surface anyway.
|
* middleware would surface anyway.
|
||||||
*
|
*
|
||||||
* - **Session but missing `Portal.Admin` privilege → 403 + audit.**
|
* - **Session but missing `admin` role → 403 + audit.** The user
|
||||||
* The user *is* authenticated; they just are not authorised
|
* *is* authenticated; they just are not authorised for admin.
|
||||||
* for admin. This is the privilege-escalation attempt audit
|
* This is the privilege-escalation attempt audit signal — every
|
||||||
* signal — every denial lands in `audit.events` with
|
* denial lands in `audit.events` with `outcome=denied`, the
|
||||||
* `outcome=denied`, the attempted route as `subject`, and the
|
* attempted route as `subject`, and the roles the user did hold
|
||||||
* `Portal.*` privileges the principal did hold in the payload's
|
* in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no
|
||||||
* `rolesHeld` field (kept named `rolesHeld` for backward
|
* action — if the audit write fails, the request fails too
|
||||||
* compatibility with the existing audit shape; the values are
|
* (consistent with the existing audit call sites in
|
||||||
* privileges per ADR-0025's renaming of the axis). Per
|
* `AuthController`).
|
||||||
* ADR-0013 §"Blocking writes": no audit ⇒ no action.
|
|
||||||
*
|
*
|
||||||
* - **Session with `Portal.Admin` privilege → pass through.**
|
* - **Session with `admin` role → pass through.** Downstream
|
||||||
* Downstream controllers see `req.session.user` *and*
|
* controllers see `req.session.user` populated and can rely on
|
||||||
* `req.session.principal` populated and can rely on the gate
|
* the role check having happened.
|
||||||
* having happened.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminRoleGuard implements CanActivate {
|
export class AdminRoleGuard implements CanActivate {
|
||||||
@@ -56,17 +48,17 @@ export class AdminRoleGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const req = context.switchToHttp().getRequest<Request>();
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
const principal = readSessionPrincipal(req);
|
const user = req.session?.user;
|
||||||
|
|
||||||
if (principal === null) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!principal.privileges.includes(ADMIN_ROLE)) {
|
if (!user.roles.includes(ADMIN_ROLE)) {
|
||||||
await this.audit.adminAccessDenied({
|
await this.audit.adminAccessDenied({
|
||||||
actor: { oid: principal.user.entraOid },
|
actor: { oid: user.oid },
|
||||||
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
attemptedRoute: `${req.method} ${req.originalUrl}`,
|
||||||
rolesHeld: [...principal.privileges],
|
rolesHeld: user.roles,
|
||||||
});
|
});
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { PrismaService } from 'nestjs-prisma';
|
|
||||||
import { AdminUsersReader } from './admin-users-reader.service';
|
|
||||||
|
|
||||||
interface MockPrisma {
|
|
||||||
userDirectoryEntry: {
|
|
||||||
count: jest.Mock;
|
|
||||||
findMany: jest.Mock;
|
|
||||||
};
|
|
||||||
$transaction: jest.Mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
// 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
|
|
||||||
// them together so Prisma's tuple-return contract is honoured.
|
|
||||||
$transaction: jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((promises: ReadonlyArray<Promise<unknown>>) => Promise.all(promises)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSubject(prisma: MockPrisma): Promise<AdminUsersReader> {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [AdminUsersReader, { provide: PrismaService, useValue: prisma }],
|
|
||||||
}).compile();
|
|
||||||
return moduleRef.get(AdminUsersReader);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROW = {
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
audience: 'workforce',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
firstSeenAt: new Date('2026-05-10T10:00:00.000Z'),
|
|
||||||
lastSeenAt: new Date('2026-05-14T08:30:00.000Z'),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AdminUsersReader.findUsers', () => {
|
|
||||||
it('issues a COUNT + SELECT in a single Prisma transaction', async () => {
|
|
||||||
const prisma = buildPrisma({ count: 5, items: [ROW] });
|
|
||||||
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(page.total).toBe(5);
|
|
||||||
expect(page.items).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('projects rows into the SPA-facing shape (ISO timestamps)', async () => {
|
|
||||||
const prisma = buildPrisma({ items: [ROW] });
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
const page = await reader.findUsers({});
|
|
||||||
expect(page.items[0]).toEqual({
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
audience: 'workforce',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
firstSeenAt: '2026-05-10T10:00:00.000Z',
|
|
||||||
lastSeenAt: '2026-05-14T08:30:00.000Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('orders by last_seen_at DESC, oid ASC for deterministic pagination', async () => {
|
|
||||||
const prisma = buildPrisma();
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
await reader.findUsers({});
|
|
||||||
const findManyArgs = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
|
||||||
orderBy: ReadonlyArray<Record<string, string>>;
|
|
||||||
};
|
|
||||||
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes startsWith filter on username through to Prisma', async () => {
|
|
||||||
const prisma = buildPrisma();
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
await reader.findUsers({ username: 'jane' });
|
|
||||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
|
||||||
where: { username?: { startsWith?: string } };
|
|
||||||
};
|
|
||||||
expect(args.where.username).toEqual({ startsWith: 'jane' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes case-insensitive contains filter on displayName through to Prisma', async () => {
|
|
||||||
const prisma = buildPrisma();
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
await reader.findUsers({ displayName: 'doe' });
|
|
||||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
|
||||||
where: { displayName?: { contains?: string; mode?: string } };
|
|
||||||
};
|
|
||||||
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('combines lastSeenAtFrom and lastSeenAtTo into a single gte/lt range filter', async () => {
|
|
||||||
const prisma = buildPrisma();
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
await reader.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 {
|
|
||||||
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
|
|
||||||
};
|
|
||||||
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
|
|
||||||
expect(args.where.lastSeenAt?.lt).toBeInstanceOf(Date);
|
|
||||||
expect(args.where.lastSeenAt?.gte?.toISOString()).toBe('2026-05-01T00:00:00.000Z');
|
|
||||||
expect(args.where.lastSeenAt?.lt?.toISOString()).toBe('2026-05-14T23:59:59.999Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies default limit (50) and offset (0) when not provided', async () => {
|
|
||||||
const prisma = buildPrisma();
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
const page = await reader.findUsers({});
|
|
||||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as {
|
|
||||||
take: number;
|
|
||||||
skip: number;
|
|
||||||
};
|
|
||||||
expect(args.take).toBe(50);
|
|
||||||
expect(args.skip).toBe(0);
|
|
||||||
expect(page.limit).toBe(50);
|
|
||||||
expect(page.offset).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps limit at MAX_LIMIT (200) even when a caller bypasses the DTO', async () => {
|
|
||||||
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 };
|
|
||||||
expect(args.take).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits no WHERE clause when no filter is provided', async () => {
|
|
||||||
const prisma = buildPrisma();
|
|
||||||
const reader = await createSubject(prisma);
|
|
||||||
await reader.findUsers({});
|
|
||||||
const args = prisma.userDirectoryEntry.findMany.mock.calls[0]?.[0] as { where: object };
|
|
||||||
expect(args.where).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
export interface AdminUserDto {
|
|
||||||
readonly oid: string;
|
|
||||||
readonly tid: string;
|
|
||||||
readonly audience: string;
|
|
||||||
readonly username: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly firstSeenAt: string;
|
|
||||||
readonly lastSeenAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsersPage {
|
|
||||||
readonly items: readonly AdminUserDto[];
|
|
||||||
readonly total: number;
|
|
||||||
readonly limit: number;
|
|
||||||
readonly offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `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.
|
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*
|
|
||||||
* Default order: `last_seen_at DESC` — the "most recently active"
|
|
||||||
* sort the admin UI most often wants. Falls back to `oid ASC` as
|
|
||||||
* a tie-breaker so pagination stays deterministic when two rows
|
|
||||||
* share a timestamp (rare, but possible during a sign-in burst).
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class AdminUsersReader {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async findUsers(filters: AdminUsersQueryDto): Promise<AdminUsersPage> {
|
|
||||||
const limit = Math.min(filters.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
|
||||||
const offset = filters.offset ?? 0;
|
|
||||||
const where = buildWhere(filters);
|
|
||||||
|
|
||||||
// Run COUNT + SELECT in a single Prisma transaction so the
|
|
||||||
// `total` reported to the SPA matches what's on the page —
|
|
||||||
// a concurrent sign-in landing between the two queries would
|
|
||||||
// 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({
|
|
||||||
where,
|
|
||||||
orderBy: [{ lastSeenAt: 'desc' }, { oid: 'asc' }],
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: items.map(toDto),
|
|
||||||
total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWhere(filters: AdminUsersQueryDto): Prisma.UserDirectoryEntryWhereInput {
|
|
||||||
const where: Prisma.UserDirectoryEntryWhereInput = {};
|
|
||||||
if (filters.username !== undefined) {
|
|
||||||
where.username = { startsWith: filters.username };
|
|
||||||
}
|
|
||||||
if (filters.displayName !== undefined) {
|
|
||||||
// Display names have natural casing variation ("Jane Doe" vs
|
|
||||||
// "jane doe") so the admin filter matches case-insensitively.
|
|
||||||
where.displayName = { contains: filters.displayName, mode: 'insensitive' };
|
|
||||||
}
|
|
||||||
if (filters.audience !== undefined) {
|
|
||||||
where.audience = filters.audience;
|
|
||||||
}
|
|
||||||
if (filters.lastSeenAtFrom !== undefined || filters.lastSeenAtTo !== undefined) {
|
|
||||||
where.lastSeenAt = {
|
|
||||||
...(filters.lastSeenAtFrom !== undefined ? { gte: new Date(filters.lastSeenAtFrom) } : {}),
|
|
||||||
...(filters.lastSeenAtTo !== undefined ? { lt: new Date(filters.lastSeenAtTo) } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return where;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserRow {
|
|
||||||
oid: string;
|
|
||||||
tid: string;
|
|
||||||
audience: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
firstSeenAt: Date;
|
|
||||||
lastSeenAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDto(row: UserRow): AdminUserDto {
|
|
||||||
return {
|
|
||||||
oid: row.oid,
|
|
||||||
tid: row.tid,
|
|
||||||
audience: row.audience,
|
|
||||||
username: row.username,
|
|
||||||
displayName: row.displayName,
|
|
||||||
firstSeenAt: row.firstSeenAt.toISOString(),
|
|
||||||
lastSeenAt: row.lastSeenAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import type { Request } from 'express';
|
|
||||||
import type { AuditWriter } from '../audit/audit.service';
|
|
||||||
import { AdminUsersController } from './admin-users.controller';
|
|
||||||
import type { AdminUsersReader, AdminUsersPage } from './admin-users-reader.service';
|
|
||||||
import type { AdminUsersQueryDto } from './users-query.dto';
|
|
||||||
|
|
||||||
const PAGE: AdminUsersPage = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
oid: 'user-oid',
|
|
||||||
tid: 'tenant-1',
|
|
||||||
audience: 'workforce',
|
|
||||||
username: 'jane.doe@apf.example',
|
|
||||||
displayName: 'Jane Doe',
|
|
||||||
firstSeenAt: '2026-05-10T10:00:00.000Z',
|
|
||||||
lastSeenAt: '2026-05-14T08:30:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 137,
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeReq(user?: { oid: string }): Request {
|
|
||||||
return { session: user !== undefined ? { user } : {} } as unknown as Request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFixture() {
|
|
||||||
const usersReader = { findUsers: jest.fn().mockResolvedValue(PAGE) };
|
|
||||||
const auditWriter = { adminUsersQuery: jest.fn().mockResolvedValue(undefined) };
|
|
||||||
const controller = new AdminUsersController(
|
|
||||||
usersReader as unknown as AdminUsersReader,
|
|
||||||
auditWriter as unknown as AuditWriter,
|
|
||||||
);
|
|
||||||
return { controller, usersReader, auditWriter };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdminUsersController.list', () => {
|
|
||||||
it('returns the page from AdminUsersReader', async () => {
|
|
||||||
const { controller } = makeFixture();
|
|
||||||
const page = await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto);
|
|
||||||
expect(page).toBe(PAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards the validated DTO to AdminUsersReader as-is', async () => {
|
|
||||||
const { controller, usersReader } = makeFixture();
|
|
||||||
const filters: AdminUsersQueryDto = {
|
|
||||||
username: 'jane',
|
|
||||||
audience: 'workforce',
|
|
||||||
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
|
|
||||||
limit: 25,
|
|
||||||
offset: 0,
|
|
||||||
};
|
|
||||||
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
|
|
||||||
expect(usersReader.findUsers).toHaveBeenCalledWith(filters);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits admin.users.query with filters + result count', async () => {
|
|
||||||
const { controller, auditWriter } = makeFixture();
|
|
||||||
const filters: AdminUsersQueryDto = { username: 'jane', limit: 50 };
|
|
||||||
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
|
|
||||||
expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith({
|
|
||||||
actor: { oid: 'admin-oid' },
|
|
||||||
filters: { username: 'jane', limit: 50 },
|
|
||||||
resultCount: PAGE.items.length,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures an empty filter object verbatim', async () => {
|
|
||||||
const { controller, auditWriter } = makeFixture();
|
|
||||||
await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto);
|
|
||||||
expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ filters: {} }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still returns the page when no session.user (defensive — guard normally catches)', async () => {
|
|
||||||
const { controller, auditWriter } = makeFixture();
|
|
||||||
const page = await controller.list(makeReq(), {} as AdminUsersQueryDto);
|
|
||||||
expect(page).toBe(PAGE);
|
|
||||||
expect(auditWriter.adminUsersQuery).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('propagates audit write failures (blocking per ADR-0013)', async () => {
|
|
||||||
const { controller, auditWriter } = makeFixture();
|
|
||||||
auditWriter.adminUsersQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
|
|
||||||
await expect(
|
|
||||||
controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto),
|
|
||||||
).rejects.toThrow('audit_writer denied');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import type { Request } from 'express';
|
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
|
||||||
import { AdminUsersReader, type AdminUsersPage } from './admin-users-reader.service';
|
|
||||||
import { RequireAdmin } from './require-admin.decorator';
|
|
||||||
import { AdminUsersQueryDto } from './users-query.dto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET /api/admin/users` — paginated, filterable view of the BFF's
|
|
||||||
* user directory per ADR-0020's v1 admin catalogue. Gated by
|
|
||||||
* `@RequireAdmin` and emits `admin.users.query` on every read so
|
|
||||||
* the directory browsing surface is itself auditable.
|
|
||||||
*
|
|
||||||
* Mirrors `AdminAuditController`'s shape so the SPA's data flow
|
|
||||||
* (filters → service → audit emit → response) follows the same
|
|
||||||
* pattern across admin modules.
|
|
||||||
*/
|
|
||||||
@ApiTags('admin (user directory)')
|
|
||||||
@ApiCookieAuth('portal_admin_session')
|
|
||||||
@Controller('admin/users')
|
|
||||||
@RequireAdmin()
|
|
||||||
export class AdminUsersController {
|
|
||||||
constructor(
|
|
||||||
private readonly usersReader: AdminUsersReader,
|
|
||||||
private readonly audit: AuditWriter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Paginated user-directory query — emits `admin.users.query` on every call',
|
|
||||||
})
|
|
||||||
@Get()
|
|
||||||
async list(@Req() req: Request, @Query() filters: AdminUsersQueryDto): Promise<AdminUsersPage> {
|
|
||||||
const page = await this.usersReader.findUsers(filters);
|
|
||||||
|
|
||||||
// Guard guarantees `req.session.user`; defensively narrow.
|
|
||||||
const actorOid = req.session.user?.oid;
|
|
||||||
if (actorOid !== undefined) {
|
|
||||||
await this.audit.adminUsersQuery({
|
|
||||||
actor: { oid: actorOid },
|
|
||||||
filters: { ...filters },
|
|
||||||
resultCount: page.items.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Controller, Get, Req } from '@nestjs/common';
|
import { Controller, Get, Req } from '@nestjs/common';
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { RequireAdmin } from './require-admin.decorator';
|
import { RequireAdmin } from './require-admin.decorator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/api/admin/*` controllers per ADR-0020. The `@RequireAdmin()`
|
* `/api/admin/*` controllers per ADR-0020. The `@RequireAdmin()`
|
||||||
* class-level decorator gates every route on the Entra `Portal.Admin`
|
* class-level decorator gates every route on the Entra `admin` role;
|
||||||
* role; downstream PRs will add per-method `@RequireMfa({ freshness: … })`
|
* downstream PRs will add per-method `@RequireMfa({ freshness: … })`
|
||||||
* for the entry route.
|
* for the entry route.
|
||||||
*
|
*
|
||||||
* `GET /api/admin/me` is the self-test endpoint named in ADR-0020's
|
* `GET /api/admin/me` is the self-test endpoint named in ADR-0020's
|
||||||
@@ -18,16 +17,13 @@ import { RequireAdmin } from './require-admin.decorator';
|
|||||||
* session → 200 + the public user payload) before any real admin
|
* session → 200 + the public user payload) before any real admin
|
||||||
* route ships.
|
* route ships.
|
||||||
*/
|
*/
|
||||||
@ApiTags('admin (self-test)')
|
|
||||||
@ApiCookieAuth('portal_admin_session')
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@RequireAdmin()
|
@RequireAdmin()
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
@ApiOperation({ summary: 'Self-test endpoint — current admin session payload + roles' })
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request) {
|
me(@Req() req: Request) {
|
||||||
// `AdminRoleGuard` has already established that `req.session.user`
|
// `AdminRoleGuard` has already established that `req.session.user`
|
||||||
// is present and has the `Portal.Admin` role — the handler can read the
|
// is present and has the `admin` role — the handler can read the
|
||||||
// field without re-checking. Using the non-null assertion keeps
|
// field without re-checking. Using the non-null assertion keeps
|
||||||
// the dead-branch noise out of the controller while leaving the
|
// the dead-branch noise out of the controller while leaving the
|
||||||
// safety contract anchored in the guard's spec.
|
// safety contract anchored in the guard's spec.
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
|
||||||
import { AdminAuditController } from './admin-audit.controller';
|
import { AdminAuditController } from './admin-audit.controller';
|
||||||
import { AdminAuthController } from './admin-auth.controller';
|
import { AdminAuthController } from './admin-auth.controller';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminRoleGuard } from './admin-role.guard';
|
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 { 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.
|
* `AdminModule` — root of the `/api/admin/*` surface per ADR-0020.
|
||||||
@@ -35,14 +29,8 @@ import { UserScopesService } from './user-scopes.service';
|
|||||||
* by `AuditModule`, so no extra import is needed for it.
|
* by `AuditModule`, so no extra import is needed for it.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, RedisModule],
|
imports: [AuthModule],
|
||||||
controllers: [
|
controllers: [AdminController, AdminAuthController, AdminAuditController],
|
||||||
AdminController,
|
providers: [AdminRoleGuard, AuditReader],
|
||||||
AdminAuthController,
|
|
||||||
AdminAuditController,
|
|
||||||
AdminUsersController,
|
|
||||||
UserScopesController,
|
|
||||||
],
|
|
||||||
providers: [AdminRoleGuard, AuditReader, AuditStatsReader, AdminUsersReader, UserScopesService],
|
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
import { AdminUsersQueryDto, MAX_LIMIT } from './users-query.dto';
|
|
||||||
|
|
||||||
const pipe = new ValidationPipe({
|
|
||||||
whitelist: true,
|
|
||||||
forbidNonWhitelisted: true,
|
|
||||||
transform: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function transform(input: unknown): Promise<AdminUsersQueryDto> {
|
|
||||||
return (await pipe.transform(input, {
|
|
||||||
type: 'query',
|
|
||||||
metatype: AdminUsersQueryDto,
|
|
||||||
})) as AdminUsersQueryDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdminUsersQueryDto', () => {
|
|
||||||
it('accepts an empty query', async () => {
|
|
||||||
await expect(transform({})).resolves.toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coerces numeric strings (URL query format) into numbers', async () => {
|
|
||||||
const out = await transform({ limit: '25', offset: '50' });
|
|
||||||
expect(out.limit).toBe(25);
|
|
||||||
expect(out.offset).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a limit above MAX_LIMIT', async () => {
|
|
||||||
await expect(transform({ limit: String(MAX_LIMIT + 1) })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a negative offset', async () => {
|
|
||||||
await expect(transform({ offset: '-1' })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a valid audience enum value', async () => {
|
|
||||||
await expect(transform({ audience: 'workforce' })).resolves.toMatchObject({
|
|
||||||
audience: 'workforce',
|
|
||||||
});
|
|
||||||
await expect(transform({ audience: 'customer' })).resolves.toMatchObject({
|
|
||||||
audience: 'customer',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown audience', async () => {
|
|
||||||
await expect(transform({ audience: 'admin' })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an ill-formed lastSeenAtFrom', async () => {
|
|
||||||
await expect(transform({ lastSeenAtFrom: 'yesterday' })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown query key (forbidNonWhitelisted)', async () => {
|
|
||||||
await expect(transform({ password: 'leak' })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an over-long username', async () => {
|
|
||||||
await expect(transform({ username: 'a'.repeat(129) })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
|
||||||
IsISO8601,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query parameters for `GET /api/admin/users`. Mirrors the
|
|
||||||
* audit-viewer DTO's pagination posture per ADR-0020 §"User list" —
|
|
||||||
* offset/limit with hard caps, every filter optional. Bound from
|
|
||||||
* the URL via Nest's `@Query()` + global ValidationPipe so unknown
|
|
||||||
* keys are rejected by `forbidNonWhitelisted` before reaching the
|
|
||||||
* handler.
|
|
||||||
*
|
|
||||||
* Filters target the `public.users` directory (PR #140). The
|
|
||||||
* `username` filter is exact-prefix match (Prisma `startsWith`)
|
|
||||||
* because audit users typically search by exact known prefix —
|
|
||||||
* `jane.doe`, `admin`, etc. `displayName` filter uses
|
|
||||||
* case-insensitive contains since display names have natural
|
|
||||||
* variation in casing.
|
|
||||||
*/
|
|
||||||
export const MAX_LIMIT = 200;
|
|
||||||
export const DEFAULT_LIMIT = 50;
|
|
||||||
|
|
||||||
const ADMIN_USERS_AUDIENCES = ['workforce', 'customer'] as const;
|
|
||||||
type AudienceFilter = (typeof ADMIN_USERS_AUDIENCES)[number];
|
|
||||||
|
|
||||||
export class AdminUsersQueryDto {
|
|
||||||
/** Exact-prefix match on `username`. */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(128)
|
|
||||||
username?: string;
|
|
||||||
|
|
||||||
/** Case-insensitive `contains` match on `display_name`. */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(128)
|
|
||||||
displayName?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(ADMIN_USERS_AUDIENCES)
|
|
||||||
audience?: AudienceFilter;
|
|
||||||
|
|
||||||
/** ISO-8601 lower bound on `last_seen_at` (inclusive). */
|
|
||||||
@IsOptional()
|
|
||||||
@IsISO8601()
|
|
||||||
lastSeenAtFrom?: string;
|
|
||||||
|
|
||||||
/** ISO-8601 upper bound on `last_seen_at` (exclusive). */
|
|
||||||
@IsOptional()
|
|
||||||
@IsISO8601()
|
|
||||||
lastSeenAtTo?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(MAX_LIMIT)
|
|
||||||
limit?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
@ApiTags('app (scaffolding)')
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|||||||
@@ -7,13 +7,9 @@ import { HealthModule } from '../health/health.module';
|
|||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.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 { RedisModule } from '../redis/redis.module';
|
||||||
import { SecurityModule } from '../security/security.module';
|
import { SecurityModule } from '../security/security.module';
|
||||||
import { SessionModule } from '../session/session.module';
|
import { SessionModule } from '../session/session.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,10 +22,6 @@ import { UsersModule } from '../users/users.module';
|
|||||||
SecurityModule,
|
SecurityModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AiBridgeModule,
|
|
||||||
DownstreamModule,
|
|
||||||
MeModule,
|
|
||||||
UsersModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -354,42 +354,6 @@ describe('AuditWriter — typed event methods', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('adminUsersQuery()', () => {
|
|
||||||
it('records admin.users.query (success) with filters + resultCount in payload', async () => {
|
|
||||||
const { writer, prisma, hashUserId } = await createSubject();
|
|
||||||
await writer.adminUsersQuery({
|
|
||||||
actor: { oid: 'admin-oid' },
|
|
||||||
filters: { username: 'jane', limit: 50 },
|
|
||||||
resultCount: 7,
|
|
||||||
});
|
|
||||||
expect(hashUserId.hash).toHaveBeenCalledWith('admin-oid');
|
|
||||||
const row = extractInsertedRow(prisma);
|
|
||||||
expect(row.eventType).toBe('admin.users.query');
|
|
||||||
expect(row.audience).toBe('workforce');
|
|
||||||
expect(row.outcome).toBe('success');
|
|
||||||
expect(row.actorIdHash).toBe('hash(admin-oid)');
|
|
||||||
expect(row.subject).toBeNull();
|
|
||||||
expect(row.payloadJson).toBe(
|
|
||||||
JSON.stringify({
|
|
||||||
filters: { username: 'jane', limit: 50 },
|
|
||||||
resultCount: 7,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists an empty filters object verbatim (admin paged through everyone)', async () => {
|
|
||||||
const { writer, prisma } = await createSubject();
|
|
||||||
await writer.adminUsersQuery({
|
|
||||||
actor: { oid: 'admin-oid' },
|
|
||||||
filters: {},
|
|
||||||
resultCount: 200,
|
|
||||||
});
|
|
||||||
expect(extractInsertedRow(prisma).payloadJson).toBe(
|
|
||||||
JSON.stringify({ filters: {}, resultCount: 200 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mfaRequired()', () => {
|
describe('mfaRequired()', () => {
|
||||||
it('records auth.mfa_required (denied) with reason + freshnessSeconds in payload', async () => {
|
it('records auth.mfa_required (denied) with reason + freshnessSeconds in payload', async () => {
|
||||||
const { writer, prisma, hashUserId } = await createSubject();
|
const { writer, prisma, hashUserId } = await createSubject();
|
||||||
@@ -469,67 +433,4 @@ describe('AuditWriter — typed event methods', () => {
|
|||||||
expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
|
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'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ import { PrismaService } from 'nestjs-prisma';
|
|||||||
import type {
|
import type {
|
||||||
AdminAccessDeniedInput,
|
AdminAccessDeniedInput,
|
||||||
AdminAuditQueryInput,
|
AdminAuditQueryInput,
|
||||||
AdminAuditStatsQueryInput,
|
|
||||||
AdminScopeGrantedInput,
|
|
||||||
AdminScopeRevokedInput,
|
|
||||||
AdminUsersQueryInput,
|
|
||||||
AuditEventInput,
|
AuditEventInput,
|
||||||
AuthorizationDeniedInput,
|
|
||||||
MfaRequiredInput,
|
MfaRequiredInput,
|
||||||
SignInActor,
|
SignInActor,
|
||||||
SignInFailedInput,
|
SignInFailedInput,
|
||||||
@@ -178,98 +173,10 @@ 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
|
|
||||||
* §"Read actions ... to deter fishing expeditions" — captures the
|
|
||||||
* filter and the result count so a reviewer can spot a sweep
|
|
||||||
* (admin paging through everyone) without joining anything.
|
|
||||||
*/
|
|
||||||
async adminUsersQuery(input: AdminUsersQueryInput): Promise<void> {
|
|
||||||
await this.recordEvent({
|
|
||||||
eventType: 'admin.users.query',
|
|
||||||
audience: 'workforce',
|
|
||||||
outcome: 'success',
|
|
||||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
||||||
payload: {
|
|
||||||
filters: input.filters,
|
|
||||||
resultCount: input.resultCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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`
|
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
||||||
* because the session's `roles` claim does not include `Portal.Admin`.
|
* because the session's `roles` claim does not include `admin`.
|
||||||
* Per ADR-0020 §"Auth — same Entra ID … `Portal.Admin` role claim", every
|
* Per ADR-0020 §"Auth — same Entra ID … `admin` role claim", every
|
||||||
* 403 from the admin surface is captured here with the attempted
|
* 403 from the admin surface is captured here with the attempted
|
||||||
* route and the roles the user actually held — auditors looking
|
* route and the roles the user actually held — auditors looking
|
||||||
* for privilege-escalation attempts pivot on `subject` (the route)
|
* for privilege-escalation attempts pivot on `subject` (the route)
|
||||||
@@ -286,34 +193,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> {
|
async recordEvent(input: AuditEventInput): Promise<void> {
|
||||||
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
||||||
const actorIdHash =
|
const actorIdHash =
|
||||||
|
|||||||
@@ -118,103 +118,6 @@ export interface AdminAuditQueryInput {
|
|||||||
resultCount: number;
|
resultCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Same shape as {@link AdminAuditQueryInput} but targets the
|
|
||||||
* `admin.users.query` event type. Per ADR-0020's deterrent posture,
|
|
||||||
* the admin user-list read is auditable just like the audit-log
|
|
||||||
* read — same payload contract, different `eventType`. A future
|
|
||||||
* refactor could fold both into a single generic typed method, but
|
|
||||||
* v1 keeps them split so the call sites declare intent at the type
|
|
||||||
* system.
|
|
||||||
*/
|
|
||||||
export interface AdminUsersQueryInput {
|
|
||||||
actor: { oid: string };
|
|
||||||
filters: Record<string, unknown>;
|
|
||||||
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. */
|
/** Reason `RequireMfaGuard` rejected a request — fed into the audit payload. */
|
||||||
export type MfaRequiredReason = 'no-mfa-in-amr' | 'no-mfa-verified-at' | 'mfa-stale';
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import type { Request, Response } from 'express';
|
|||||||
import type { Logger } from 'nestjs-pino';
|
import type { Logger } from 'nestjs-pino';
|
||||||
import type { AuditWriter } from '../audit/audit.service';
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
||||||
import type { UserDirectoryService } from '../users/user-directory.service';
|
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { PRE_AUTH_COOKIE_NAME, PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie';
|
import { PRE_AUTH_COOKIE_NAME, PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie';
|
||||||
import { AuthCodeFlowException } from './auth.errors';
|
import { AuthCodeFlowException } from './auth.errors';
|
||||||
@@ -144,47 +143,13 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller
|
|||||||
sessionExpired: jest.fn().mockResolvedValue(undefined),
|
sessionExpired: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const logger = makeLoggerStub();
|
const logger = makeLoggerStub();
|
||||||
// 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
|
// Real SessionEstablisher with the same mocks the legacy tests
|
||||||
// already wire — keeps the behavioural assertions on session
|
// already wire — keeps the behavioural assertions on session
|
||||||
// fields / audit calls untouched after the controller refactor.
|
// 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(
|
const sessionEstablisher = new SessionEstablisher(
|
||||||
logger as unknown as Logger,
|
logger as unknown as Logger,
|
||||||
userSessionIndex as unknown as UserSessionIndexService,
|
userSessionIndex as unknown as UserSessionIndexService,
|
||||||
audit as unknown as AuditWriter,
|
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 {
|
return {
|
||||||
controller: new AuthController(
|
controller: new AuthController(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
@@ -25,7 +24,6 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
* redirects to Entra's RP-initiated logout endpoint so the user is
|
* redirects to Entra's RP-initiated logout endpoint so the user is
|
||||||
* signed out at the IdP too.
|
* signed out at the IdP too.
|
||||||
*/
|
*/
|
||||||
@ApiTags('auth (user portal)')
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -46,9 +44,6 @@ export class AuthController {
|
|||||||
* server-side state. That keeps `/login` stateless and friendly
|
* server-side state. That keeps `/login` stateless and friendly
|
||||||
* to horizontal scaling once the BFF runs more than one instance.
|
* to horizontal scaling once the BFF runs more than one instance.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Start the OIDC Auth Code + PKCE flow (302 to Entra)',
|
|
||||||
})
|
|
||||||
@Get('login')
|
@Get('login')
|
||||||
async login(@Res() res: Response): Promise<void> {
|
async login(@Res() res: Response): Promise<void> {
|
||||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
||||||
@@ -77,9 +72,6 @@ export class AuthController {
|
|||||||
* specific message. The pre-auth cookie is cleared on every
|
* specific message. The pre-auth cookie is cleared on every
|
||||||
* exit path: it is single-use by design.
|
* exit path: it is single-use by design.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Entra callback — completes the OIDC exchange, establishes the session, 302 to SPA',
|
|
||||||
})
|
|
||||||
@Get('callback')
|
@Get('callback')
|
||||||
async callback(
|
async callback(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@@ -154,8 +146,6 @@ export class AuthController {
|
|||||||
* the browser's session cookie was either absent, expired, or
|
* the browser's session cookie was either absent, expired, or
|
||||||
* pointed at a Redis key that no longer exists.
|
* pointed at a Redis key that no longer exists.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({ summary: 'Current session payload (curated public view)' })
|
|
||||||
@ApiCookieAuth('portal_session')
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request, @Res() res: Response): void {
|
me(@Req() req: Request, @Res() res: Response): void {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -186,8 +176,6 @@ export class AuthController {
|
|||||||
* SameSite=Lax (subresource requests don't carry the cookie); a
|
* SameSite=Lax (subresource requests don't carry the cookie); a
|
||||||
* dedicated CSRF middleware lands with phase-2 security.
|
* dedicated CSRF middleware lands with phase-2 security.
|
||||||
*/
|
*/
|
||||||
@ApiOperation({ summary: 'RP-initiated logout — destroys session + 302 to Entra logout' })
|
|
||||||
@ApiCookieAuth('portal_session')
|
|
||||||
@Get('logout')
|
@Get('logout')
|
||||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { PrismaService } from 'nestjs-prisma';
|
import { PrismaService } from 'nestjs-prisma';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
|
||||||
import { AuthModule } from './auth.module';
|
import { AuthModule } from './auth.module';
|
||||||
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
||||||
import { MSAL_CLIENT } from './msal-client.token';
|
import { MSAL_CLIENT } from './msal-client.token';
|
||||||
@@ -68,7 +67,6 @@ async function compile() {
|
|||||||
LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }),
|
LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }),
|
||||||
TestStubsModule,
|
TestStubsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
UsersModule,
|
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user