Files
julien 2a5ab4fb46
CI / check (push) Successful in 2m8s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m23s
CI / a11y (push) Successful in 1m32s
CI / perf (push) Successful in 6m15s
fix(gitignore): ignore infra/*-tenant.personas.json (ADR-0026) (#268)
## Summary

Add `infra/*-tenant.personas.json` to `.gitignore` mirroring the existing `*-tenant.entra.json` pattern, so the per-persona Entra `oid` map consumed by `apps/portal-bff/prisma/seed.ts` (ADR-0026) cannot accidentally be committed. The schema template `infra/test-tenant.personas.example.json` was already in place; the matching ignore was missed when the personas file was introduced.

## Background

ADR-0026 §"Seed personas" introduced two parallel tenant-private files:

- **`*-tenant.entra.json`** — Entra security-group GUIDs → role-slug map (24 entries). Has been correctly ignored since shipped (`.gitignore` lines 34-36).
- **`*-tenant.personas.json`** — Per-persona Entra `oid` map (19 entries). The example template was committed correctly but the matching ignore pattern was never added.

Practical consequence: `infra/test-tenant.personas.json` has been showing up as untracked (`??`) in `git status` on the R&D Lead's machine since the seed personas work landed in late May, surviving multiple sessions and PR opens — and could have been accidentally `git add .`-ed at any point. Confirmed via `git log --all -- infra/test-tenant.personas.json` (empty) that no commit has touched it yet, so no history rewrite needed — pure forward-only fix.

## What lands

| File | Change |
| --- | --- |
| `.gitignore` | Three new lines mirroring the entra pattern: a comment, `infra/*-tenant.personas.json` (ignore), `!infra/*-tenant.personas.example.json` (keep the template tracked). |
| `infra/README.md` | New row in the top-level table for the per-persona oid map, pointing at the `.example.json` template and ADR-0026, right under the existing entra row. |

## Verification

- [x] `git check-ignore -v infra/test-tenant.personas.json` → matched by `.gitignore:40:infra/*-tenant.personas.json`.
- [x] `git check-ignore -v infra/test-tenant.personas.example.json` → not ignored (the `!` negation correctly preserves the example template as a tracked file).
- [x] `git status --short` no longer surfaces the `??` for the personas file.
- [x] `git log --all -- infra/test-tenant.personas.json` confirms zero commits ever referenced this path, so no `git rm --cached` or history rewrite is required.

## Related

- [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) — entra group map, the pattern this PR copies.
- [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) — Person/User/UserScope seed which consumes the personas map.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #268
2026-06-02 12:24:58 +02:00

484 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `infra/`
Infrastructure-as-code artefacts for the project. Separate from application code and from documentation: this folder contains the recipes and configs that the team and ops use to stand up running infrastructure (CI runners, future local-dev databases, future on-prem deploy assets).
| Subject | File / Folder | ADR / Reference |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Self-hosted CI runners (Gitea Actions) | [`ci-runners.compose.yml`](ci-runners.compose.yml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) |
| Shared `act_runner` configuration | [`runner-config.yaml`](runner-config.yaml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) |
| CI runners convenience script | [`ci-runners.sh`](ci-runners.sh) | See "Convenience script" below |
| Runtime state of the runners | `data/` (git-ignored after `.gitignore`) | — |
| Env-vars template for the runners | `.env.example` (`.env` is git-ignored) | — |
| Local-dev runtime stack | [`local/`](local/) | [ADR-0006](../docs/decisions/0006-persistence-postgresql-prisma.md), [ADR-0010](../docs/decisions/0010-session-management-redis.md), [ADR-0012](../docs/decisions/0012-observability-pino-opentelemetry.md), [ADR-0013](../docs/decisions/0013-audit-trail-separated-postgres-append-only.md) |
| Entra group GUID → role slug map | [`test-tenant.entra.example.json`](test-tenant.entra.example.json) (`*-tenant.entra.json` is git-ignored) | [ADR-0025 §"Sources of truth — Entra-side configuration"](../docs/decisions/0025-authorization-model-privileges-roles-scopes.md) |
| Per-persona Entra `oid` map | [`test-tenant.personas.example.json`](test-tenant.personas.example.json) (`*-tenant.personas.json` is git-ignored) | [ADR-0026 §"Seed personas"](../docs/decisions/0026-person-user-portal-data-model.md) — consumed by `apps/portal-bff/prisma/seed.ts` |
Future folders / files that will land here as the corresponding ADRs ship:
- **`prod/`** — On-prem deploy manifests (HA Postgres, Redis Sentinel, OTel collector + backend, secret manager). Triggered by the on-prem infrastructure ADR (phase 3b).
---
## CI runners — `ci-runners.compose.yml`
Three self-hosted [`act_runner`](https://gitea.com/gitea/act_runner) instances, registered with the project's Gitea organisation, labelled `self-hosted` + `on-prem` (the labels referenced by every job in `.gitea/workflows/*`). Three matches the floor recommended by [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) — one runner is enough to validate the pipeline; two leave no slack; three keep CI flowing if one runner is down for upgrade or maintenance.
### First-time registration
```bash
cd infra/
# 1. Generate a registration token in Gitea.
# Site Administration → Actions → Runners → "Create new Runner"
# (or, for org-scoped runners: Organisation Settings → Actions → Runners).
# The token is one-time and short-lived; don't lose it.
# 2. Configure .env (which is git-ignored).
cp .env.example .env
$EDITOR .env
# Set GITEA_INSTANCE_URL (https, no trailing slash) and
# GITEA_RUNNER_REGISTRATION_TOKEN.
# 3. Pre-pull the job images and bring the runners up. The script
# chains the two — see "Job image pinning and pre-pull" below
# for the rationale.
./ci-runners.sh up --prepull
# 4. Verify in Gitea: the three runners appear as online with the
# self-hosted, on-prem labels. If a runner doesn't come online,
# inspect its logs:
./ci-runners.sh logs runner-1
```
After the first successful boot, each runner stores its credentials under `data/runner-N/.runner`. The registration token is no longer needed and **should be removed** from `.env`. Subsequent restarts (`./ci-runners.sh restart …` or direct `docker compose restart …`) authenticate from the persisted credential.
### Convenience script — `ci-runners.sh`
[`ci-runners.sh`](ci-runners.sh) is a thin wrapper around `docker compose -f ci-runners.compose.yml ...` for the everyday verbs. Two reasons to use it:
1. **Hides the compose-file path** on every command. `./ci-runners.sh up` instead of `docker compose -f ci-runners.compose.yml up -d`.
2. **`rotate` automates the rolling restart** the "Operational tips" below recommend: runner-1 → wait → runner-2 → wait → runner-3, so the CI pipeline always has at least N-1 runners online while you push a config change.
| Command | Effect |
| ---------------------------------- | ------------------------------------------------------------------------------ |
| `./ci-runners.sh up` | Bring the three runner containers up |
| `./ci-runners.sh up --prepull` | Pre-pull the job images (`act-22.04` + `:full-22.04`) on the host first |
| `./ci-runners.sh down` | Stop and remove the containers (preserves `data/runner-N/.runner` credentials) |
| `./ci-runners.sh restart <runner>` | Restart one runner |
| `./ci-runners.sh rotate` | Rolling restart of every runner with a 15 s pause between each |
| `./ci-runners.sh status` | `docker compose ps` for the runner services |
| `./ci-runners.sh logs [runner]` | Follow logs (one runner or all of them) |
| `./ci-runners.sh pull-images` | Pre-pull / refresh the job images (idempotent) |
Anything not matching one of the named verbs is passed through to `docker compose -f ci-runners.compose.yml ...`. Run `./ci-runners.sh help` for the full reference.
For the destructive `down -v` (wipes `data/`, forces re-registration with a fresh Gitea token), the script intentionally **doesn't** offer a verb — invoke `docker compose -f ci-runners.compose.yml down -v` directly so the path is explicit at the typing level.
### Operational tips
- **Rotation of one runner at a time** — to upgrade the image or change config, run `./ci-runners.sh rotate` (or restart manually one by one — `./ci-runners.sh restart runner-1`, wait, …) so the CI pipeline is never starved.
- **Logs** — `./ci-runners.sh logs runner-N` (or `docker compose logs -f --tail=100 runner-N`) for a single runner; jobs being executed appear here.
- **Disk pressure** — the runner caches each job's container image in `/var/lib/docker` on the host. On a small host, prune periodically (`docker system prune -af` while no job is running).
- **Adding a fourth runner** — copy any `runner-N` block in the compose file, increment the suffix in `container_name`, `GITEA_RUNNER_NAME`, and the `data/` mount path. Add the new name to the `RUNNERS=(…)` array at the top of `ci-runners.sh` so `rotate` and `restart` learn about it. Then `./ci-runners.sh up` (or `docker compose up -d`). The runner registers using the same `GITEA_RUNNER_REGISTRATION_TOKEN` (which must be regenerated if it has expired).
### Security — Docker socket exposure
The compose mounts `/var/run/docker.sock` into each runner so jobs can spawn containers. **This grants the runner root-equivalent access to the host's Docker daemon.** A malicious workflow could spawn arbitrary containers, mount host paths, escalate privileges. Mitigations:
- **Trust boundary:** only register the runners against repositories controlled by the org. Gitea's runner-registration UI lets you scope a runner to an organisation, a single repository, or instance-wide. Prefer the narrowest scope.
- **Dedicated host:** run these containers on a host that does not also run production services or hold sensitive data. The runner host is in the trust boundary of any developer who can push to a repo it serves.
- **No host filesystem mounts beyond the docker socket:** the compose intentionally does not mount `/`, `/etc`, or any project source. Workflows that need data on the host must do so via Docker volumes.
- **Future hardening (out of scope of v1):** migrate to **rootless Docker** on the runner host, or to a **DinD (Docker-in-Docker) sidecar** so the runner cannot escape into the host daemon. Decided when the org's RSSI confirms the security posture, or when the runner host is shared with anything else of value.
### Cache server
`act_runner` ships a built-in GitHub-Actions-cache-compatible server, used by `actions/setup-node@v6` (`cache: 'pnpm'`), `actions/cache`, and similar. The default behaviour does **not** work in our compose-based setup: the runner container is on the compose-defined `apf-portal-act-runners` bridge, while jobs spawned through the mounted `/var/run/docker.sock` come up on Docker's anonymous `bridge` network — the cache server binds inside the runner on a random port, advertises an IP on the runners' bridge, and the job can't reach it. The symptom is a ~2 min `ETIMEDOUT` at the start (restore) and end (save) of every job that opts into caching.
The fix is in [`runner-config.yaml`](runner-config.yaml): `container.network: apf-portal-act-runners` instructs `act_runner` to attach every job container to the same compose-defined bridge as the runners. Job → runner is now an internal-network DNS hop, the advertised cache URL is reachable, and `cache: 'pnpm'` works end-to-end. The `cache: 'pnpm'` flag is enabled on every `actions/setup-node` step in `.gitea/workflows/ci.yml` and `.gitea/workflows/security-scheduled.yml`.
The blast-radius trade-off is bounded: every container on `apf-portal-act-runners` is one of our runner containers (plus the jobs they spawn), all of which already have full docker-socket access. Sharing a network does not widen what a malicious workflow can already do; it just lets jobs reach the cache server.
If the cache ever needs to be disabled (debugging cache-hit issues, etc.), set `cache.enabled: false` in `runner-config.yaml` and `./ci-runners.sh rotate`.
### `act_runner` image pinning
The compose pins `gitea/act_runner:0.2.13`. Update the pin deliberately, not via `:latest`:
1. Read the act_runner [release notes](https://gitea.com/gitea/act_runner/releases) for breaking changes.
2. Edit the three image references (`runner-1`, `runner-2`, `runner-3`).
3. Commit on a feature branch with a `chore(deps):` Conventional Commits subject.
4. Roll one runner at a time (rotation tip above).
The matching CI workflows refer to runner _labels_ (not images), so a runner-image upgrade does not affect `.gitea/workflows/*`.
### Job image pinning and pre-pull
`act_runner` runs each job inside a container whose image is selected by the runner's _labels_. Two images are in use:
| Label | Image | Used by |
| ---------------------- | -------------------------------- | ---------------------------------- |
| `self-hosted` | `catthehacker/ubuntu:act-22.04` | `check`, `scan`, `commits`, `a11y` |
| `on-prem` | `catthehacker/ubuntu:act-22.04` | (alias of `self-hosted`) |
| (per-job `container:`) | `catthehacker/ubuntu:full-22.04` | `perf` (Lighthouse needs Chrome) |
[`runner-config.yaml`](runner-config.yaml) sets `container.force_pull: false`. Without that, act_runner re-issues a `docker pull` at the start of every single job (~1030 s of registry round-trip even when every layer is already cached), which both wastes wall-clock and contradicts our policy of upgrading job images deliberately rather than implicitly via `:latest`.
The trade-off: the host Docker daemon must already hold the images locally. Pre-pull them once after a fresh runner host install:
```bash
docker pull catthehacker/ubuntu:act-22.04
docker pull catthehacker/ubuntu:full-22.04
```
Upgrading to a newer tag is a deliberate three-step process:
1. Edit `GITEA_RUNNER_LABELS` (in [`ci-runners.compose.yml`](ci-runners.compose.yml)) and / or the per-job `container.image:` (in `.gitea/workflows/*`) to the new tag.
2. On the runner host, `docker pull <new-tag>` so the image is locally available before the next CI job starts.
3. Commit on a feature branch with a `chore(deps):` Conventional Commits subject; one of `chore(deps): upgrade CI job image to ...`.
Old, no-longer-referenced images can be reaped during the periodic `docker system prune -af` (see "Disk pressure" above).
---
## Local-dev stack — `local/`
A Docker Compose recipe spinning up the runtime services the BFF and ADRs assume — Postgres, Redis, OpenTelemetry Collector — plus optional viewers / tooling (pgweb, Jaeger UI, Caddy serve-static) gated behind Compose profiles. Designed to start in a single command on a contributor's WSL2 / Linux / macOS host.
| File | Role |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below |
| [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy / the `apps` dev servers behind profiles |
| [`local/Dockerfile.dev`](local/Dockerfile.dev) | Dev-only image (Node 24 + corepack) shared by the three `apps`-profile dev servers (ADR-0030) |
| [`local/dev-entrypoint.sh`](local/dev-entrypoint.sh) | Entrypoint for the `apps` services: BFF runs `prisma generate` + `migrate deploy`, then each runs `nx serve` |
| [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) |
| [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only |
| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active |
| [`local/Caddyfile`](local/Caddyfile) | Reverse-proxy config for the `serve-static` profile — per-locale SPA fallback + smart `/` redirect (ADR-0019) |
### First-time setup
```bash
# 1. Configure local secrets (copy template, edit, do not commit).
cp infra/local/.env.example infra/local/.env
$EDITOR infra/local/.env
# Set strong dev values for POSTGRES_PASSWORD and REDIS_PASSWORD
# (defaults in the template are placeholders that the compose
# rejects with `must be set in infra/local/.env` if left as-is).
# 2. Bring up the core stack (postgres + redis + otel-collector).
./infra/local/dev.sh up
# 3. (Optional) Activate viewers / tooling when needed:
./infra/local/dev.sh up dbtools # adds pgweb
./infra/local/dev.sh up observability # adds Jaeger UI
./infra/local/dev.sh up serve-static # adds caddy serving the prod build
./infra/local/dev.sh up all # core + every profile
# 4. Verify health.
./infra/local/dev.sh status
```
### Convenience script — `dev.sh`
[`local/dev.sh`](local/dev.sh) is a thin wrapper around `docker compose -f dev.compose.yml ...` with two reasons to exist:
1. **Hides the Compose-profile gotcha.** `docker compose down` only operates on services whose profile is currently active — anything started under `--profile X` keeps running unless the same flag is on `down`. The script always passes every profile in scope on teardown / status / log commands, so profile-gated services (pgweb, Jaeger) are never accidentally orphaned.
2. **Ergonomic verbs** for the common workflows. `./dev.sh up all`, `./dev.sh stop pgweb`, `./dev.sh logs otel-collector`, etc.
Run `./infra/local/dev.sh help` for the full reference. Cheat-sheet:
| Command | Effect |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `./infra/local/dev.sh up` | Core only (postgres + redis + otel-collector) |
| `./infra/local/dev.sh up all` | Core + dbtools + observability + apps (full dev stack). serve-static is excluded — it would collide with apps on port 4200 |
| `./infra/local/dev.sh up dbtools` | Core + pgweb |
| `./infra/local/dev.sh up observability` | Core + Jaeger |
| `./infra/local/dev.sh up serve-static` | Core + Caddy serving `dist/.../browser/` per ADR-0019 |
| `./infra/local/dev.sh up apps` | Core + the three Nx dev servers in Docker (ADR-0030) |
| `./infra/local/dev.sh down` | Tear down the whole stack (every profile in scope) |
| `./infra/local/dev.sh down -v` | Tear down + wipe named volumes (incl. audit-roles bootstrap) |
| `./infra/local/dev.sh stop pgweb` | Stop one service (containers stay around) |
| `./infra/local/dev.sh status` | `docker compose ps`, with every profile visible |
| `./infra/local/dev.sh logs otel-collector` | Follow logs |
| `./infra/local/dev.sh exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"` | Run a command inside a service |
Anything not matching one of the named verbs is passed through to `docker compose -f dev.compose.yml ...` (with every profile flagged in), so you keep the full Compose surface available — `./dev.sh config`, `./dev.sh top`, `./dev.sh inspect …`, etc.
If you prefer to call `docker compose` directly, every example below shows the raw command alongside the script form.
### Dockerised app dev mode — `apps` profile (ADR-0030)
The `apps` profile runs the three Nx dev servers **in Docker**, so a contributor can bring up the whole stack without installing Node / pnpm natively:
```bash
./infra/local/dev.sh up apps # infra + portal-bff:3000 + portal-shell:4200 + portal-admin:4300
```
How it works (see [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md)):
- A single [`Dockerfile.dev`](local/Dockerfile.dev) (Node 24 + corepack) backs all three services — one image, one install for the monorepo.
- The repo is bind-mounted for hot reload; `node_modules` and the Nx cache live in named volumes (`apf-portal-app-node-modules`, `apf-portal-app-nx-cache`) so the container's native modules are never shadowed by the host's.
- A one-shot `apps-deps` service runs `pnpm install` once into the shared volume; the three servers gate on its completion, avoiding a three-way install race.
- The BFF entrypoint runs `prisma generate` + `prisma migrate deploy` before serving.
**Prerequisite — the BFF still needs its secrets.** No native toolchain is required, but `apps/portal-bff/.env` (Entra / session / jwks config) must exist, same as native dev (`cp apps/portal-bff/.env.example apps/portal-bff/.env` then fill it). The host-specific URLs (`DATABASE_URL` / `REDIS_URL` / OTel endpoint) are overridden automatically to the Compose service names — you don't edit those for the container. SPA-only work (`up portal-shell`) doesn't need the BFF env.
**Port note.** The SPA dev servers default to 4200 / 4300 — 4200 is the same port the `serve-static` profile uses. Don't run `apps` and `serve-static` together, or set `SHELL_PORT` in `infra/local/.env`.
The three dev modes (native `nx serve`, devcontainer, this `apps` profile) and when to use each are summarised in [docs/setup/01-dev-debian-vm-setup.md](../docs/setup/01-dev-debian-vm-setup.md).
### Switching between dev modes — `localhost` vs hostname
Within the `apps` profile, there are **two access modes** for the SPA. They differ only in how the browser reaches the dev-servers — the BFF and the rest of the stack are unchanged. The toggle is a single file: [`infra/local/.env`](local/.env.example).
| Mode | When to use | What flips |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **A — `localhost` (default)** | Solo dev, browser on the same workstation as the VSCode Remote-SSH client. No cert plumbing, no `hosts` file change. The fastest path to a working stack. | Nothing — `infra/local/.env` is the default. |
| **B — HTTPS hostname** | A teammate (or PM / QA) needs to browse YOUR VM from THEIR machine, or you have a shared VM. Requires the team mkcert CA already installed (see "Team mkcert CA on `vm-gitlab`" below) and `apf-portal.dev-XX.local` in their `hosts` file. | `NX_SERVE_CONFIGURATION=https` plus the four `ENTRA_*_REDIRECT_URI` lines in `infra/local/.env` (commented template in `.env.example`). |
The toggle works because Compose's `environment:` block on the `portal-bff` service interpolates each `ENTRA_*_REDIRECT_URI` from `infra/local/.env` with a `localhost` fallback, and wins over `env_file:`. As a result `apps/portal-bff/.env` keeps its native-friendly `localhost` defaults regardless of mode — native `nx serve` (no Docker) and Mode A both read the same `.env` cleanly, while Mode B sees the override only inside the container.
#### Mode A — `localhost` via VSCode port forwarding
1. Make sure `infra/local/.env` does NOT set `NX_SERVE_CONFIGURATION` (or sets it to `development`) and leaves the four `ENTRA_*_REDIRECT_URI` lines commented (this is the `.env.example` default).
2. `./infra/local/dev.sh up apps`.
3. VSCode Remote-SSH auto-discovers the published ports (panel **PORTS** at the bottom of VSCode) and forwards them to your workstation. If a port doesn't show up, "Forward a Port" manually (4200, 4300, 3000).
4. Open `http://localhost:4200/` on the workstation. The SPA loads, fetches `/api/...` (proxied to the BFF inside Compose), and the OIDC callback at `http://localhost:3000/api/auth/callback` (already registered in Entra) is reachable through the same VSCode tunnel.
Nothing else to configure. No mkcert. No `hosts` file. No cert warning.
#### Mode B — HTTPS hostname (`apf-portal.dev-XX.local`)
Detailed setup in the next two subsections ("HTTPS dev-server setup" + "Team mkcert CA on `vm-gitlab`"). Once the workstation has the team CA installed and the host file knows the hostname, switching is:
1. In `infra/local/.env`, uncomment the five Mode B lines (and replace `dev-jg` with the right hostname).
2. `./infra/local/dev.sh down && ./infra/local/dev.sh up apps`.
3. Browse `https://apf-portal.dev-XX.local:4200/`.
To switch back to Mode A: comment those five lines, `down && up apps`. No other file touched.
### HTTPS dev-server setup — remote-browser access via a hostname
By default the dev-servers serve plain HTTP — fine when the browser is on the same host as the BFF (`http://localhost:4200/`), which is also the only HTTP origin Entra accepts as a redirect URI. The moment you access the SPA over a **hostname** (e.g. `apf-portal.dev.local`, useful when the browser sits on a workstation and the stack runs on a shared / per-dev VM), Entra refuses the `http:` redirect URI and the dev-servers must terminate TLS.
The setup is one-time per dev:
1. **Install [mkcert](https://github.com/FiloSottile/mkcert)** on your workstation (the machine where the browser runs) and bootstrap its local CA:
```bash
# Debian / WSL Ubuntu:
sudo apt install -y libnss3-tools
# macOS:
# brew install mkcert nss
# Windows (PowerShell, choco):
# choco install mkcert
mkcert -install
```
2. **Generate the cert** for the hostname you registered in your `/etc/hosts` and in Entra. From the repo root on your workstation:
```bash
mkdir -p .secrets
mkcert -key-file .secrets/dev-tls.key -cert-file .secrets/dev-tls.pem \
apf-portal.dev-jg.local # ← replace with YOUR hostname
```
`.secrets/` is git-ignored; the bind-mount in the `apps` profile (the repo at `/workspace`) makes the files visible inside the containers at the path the `https` configuration expects.
3. **Update `apps/portal-bff/.env`** so the BFF tells Entra the matching HTTPS URIs — see the redirect-URI block in [`apps/portal-bff/.env.example`](../apps/portal-bff/.env.example) for the override pattern. The same URIs must be registered in your Entra app registration's "Redirect URIs" list (the BFF only sends one of them per auth request; Entra validates it is on the list).
4. **Enable the `https` Nx serve configuration** for the compose dev-servers by adding to `infra/local/.env`:
```env
NX_SERVE_CONFIGURATION=https
```
The compose command resolves `--configuration=${NX_SERVE_CONFIGURATION:-development}` at parse time, so the SPAs pick up the `https` config defined in `apps/portal-shell/project.json` and `apps/portal-admin/project.json`. The BFF stays HTTP behind the proxy — only the public origin is HTTPS.
5. `./infra/local/dev.sh up apps` → browser opens `https://apf-portal.dev-jg.local:4200/`. No cert warning (mkcert's CA is trusted by the workstation after step 1).
Native `nx serve` (WSL / localhost) is **unaffected** — it keeps using the `development` configuration by default, no SSL required, and the `localhost` URIs registered in Entra still work.
When real DNS + corp-CA-signed certs arrive, the hostname can be reused as-is (Entra registrations are literal strings — they don't care who signs the cert). Drop the cert files back into `.secrets/` and remove the mkcert step.
### Team mkcert CA on `vm-gitlab` — sharing the trust root
The previous section is the **solo flow** (one dev mints their own CA, certs only trusted by their own workstation). It does not let a teammate browse another dev's VM without a certificate warning — every dev has their own private CA, none of which the others trust.
For a multi-dev team the canonical pattern is one shared CA held on `vm-gitlab`. The CA private key (`rootCA-key.pem`) stays on `vm-gitlab` — never copied to any workstation; only the public `rootCA.pem` is distributed to each developer's Windows trust store, and the R&D Lead mints per-VM certs on `vm-gitlab` when a new VM (or new developer) joins. Browsing any dev VM from any workstation then "just works" — green padlock, no warning.
This subsection assumes the per-dev workstation procedure of "HTTPS dev-server setup" above is what every developer will do **once**, with the rootCA.pem they receive from this shared CA.
#### Initial setup on `vm-gitlab` (one-time, by the R&D Lead)
```bash
# 1. Install mkcert on vm-gitlab (no service to run — mkcert is one-shot).
sudo curl -fsSL https://dl.filippo.io/mkcert/latest?for=linux/amd64 \
-o /usr/local/bin/mkcert
sudo chmod +x /usr/local/bin/mkcert
# 2. Create the shared CAROOT, root-only.
sudo mkdir -p /srv/apf-portal/mkcert-ca
sudo chown root:root /srv/apf-portal/mkcert-ca
sudo chmod 700 /srv/apf-portal/mkcert-ca
# 3. Generate the CA into that CAROOT. (`-install` here just touches
# the local trust store of vm-gitlab — cosmetic for an infra VM,
# no harm.)
sudo CAROOT=/srv/apf-portal/mkcert-ca mkcert -install
# 4. Verify.
sudo ls -la /srv/apf-portal/mkcert-ca/
# → rootCA.pem (-rw-r--r--), rootCA-key.pem (-rw-------, root only)
```
After this, the CA exists and is owned by `root` on `vm-gitlab`. Developers never touch it directly.
#### Minting a cert for a dev VM (R&D Lead, on `vm-gitlab`)
Repeat once per VM hostname (`apf-portal.dev-jg.local`, `apf-portal.dev-vc.local`, `apf-portal.dev.local`, …). Replace `<host>` and the SSH/scp target accordingly:
```bash
sudo CAROOT=/srv/apf-portal/mkcert-ca mkcert \
-key-file /tmp/<host>-tls.key \
-cert-file /tmp/<host>-tls.pem \
apf-portal.<host>.local
# Sanity check.
sudo openssl x509 -in /tmp/<host>-tls.pem -noout -subject -issuer
# subject CN must be apf-portal.<host>.local; issuer the mkcert CA name.
# Ship to the target VM, renaming to the path the `https` Nx serve
# configuration expects (.secrets/dev-tls.{key,pem}).
sudo scp /tmp/<host>-tls.key <vm>:~/Works/apf_portal/.secrets/dev-tls.key
sudo scp /tmp/<host>-tls.pem <vm>:~/Works/apf_portal/.secrets/dev-tls.pem
# Wipe the staging copies.
sudo rm /tmp/<host>-tls.*
```
The certificate is good for ~2 years (mkcert default). When it nears expiry, regenerate with the same command and re-`scp` — the dev-server picks up the new files on next restart.
#### Onboarding a new developer
A new teammate needs **three things**: a copy of `rootCA.pem` (public, low-sensitivity), a per-VM cert minted by the R&D Lead, and the same hosts-file + `.env` configuration every dev follows.
**R&D Lead side** — on `vm-gitlab`:
```bash
# Hand off the public CA cert to the new dev via a secure channel
# (1Password shared vault, Bitwarden, direct scp). Never plain e-mail.
sudo cat /srv/apf-portal/mkcert-ca/rootCA.pem
```
Then mint that dev's per-VM cert (see "Minting a cert for a dev VM" above) and ship it to their VM's `~/Works/apf_portal/.secrets/`.
**New developer side** — on their Windows workstation:
```powershell
# 1. Install mkcert (only to get the `-install` command — no need to
# generate certs on the workstation).
choco install mkcert -y
# 2. Drop the rootCA.pem they received into the local CAROOT path.
$caroot = mkcert -CAROOT
Copy-Item "C:\path\to\rootCA.pem" "$caroot\rootCA.pem"
# NB: only rootCA.pem — they do NOT receive rootCA-key.pem.
# 3. Register the team CA in their Windows trust store.
mkcert -install
# Confirm the Windows security dialog. Their machine now trusts every
# cert minted by the team CA on vm-gitlab.
```
Then they:
- Edit `C:\Windows\System32\drivers\etc\hosts` (admin) and add the entries for every VM they want to reach (their own + the others as needed):
```
10.100.201.20 apf-portal.dev-vc.local
10.100.201.21 apf-portal.dev-jg.local
10.100.201.22 apf-portal.dev.local
```
- Edit `apps/portal-bff/.env` on their VM so the four `ENTRA_*_REDIRECT_URI` values point at `https://apf-portal.<their-host>:{4200,4300}/...` (the matching URIs are already registered Entra-side — no action there).
- Set `NX_SERVE_CONFIGURATION=https` in `infra/local/.env` on their VM.
- `./infra/local/dev.sh down && ./infra/local/dev.sh up apps`.
Total onboarding budget: ~5 min of R&D Lead time on `vm-gitlab` (mint + transfer) + ~10 min of work on the new dev's workstation + VM. No SSH access to `vm-gitlab` is granted to developers — only the R&D Lead operates the CA.
#### Operational notes
- **Departures.** mkcert has no CRL; revoking trust on a former dev's machine isn't actionable from the CA side. The risk surface is what that dev could have signed before leaving — and they only ever had the public `rootCA.pem`, never the private key, so they cannot have signed anything in your trust circle. No action required when a dev leaves.
- **CA rotation.** Rare (audit, suspected compromise, annual hygiene). Regenerate the CA on `vm-gitlab`, re-mint every VM's cert, redistribute the new `rootCA.pem` to each dev. Each dev re-imports + re-`mkcert -install`. No `.env` or Entra change.
- **Per-VM cert rotation.** Same pattern as initial mint — regenerate, scp, `dev.sh restart portal-shell portal-admin`. No client-side action.
- **Migration to a corp-signed CA.** When the infra team issues an internal-CA-signed cert (already trusted by every domain-joined workstation, no mkcert step), drop those files into `.secrets/dev-tls.{key,pem}` and remove the team mkcert CA from each dev's trust store. Entra registrations are unchanged — they reference hostname + port, not the issuer.
### Service endpoints (defaults)
| Service | Host port | Purpose |
| ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Postgres | 5432 | DB connection — `postgres://portal:<pwd>@localhost:5432/portal_dev` |
| Redis | 6379 | Sessions, OBO cache (per ADR-0010 / ADR-0014) |
| OTel Collector gRPC | 4317 | `OTEL_EXPORTER_OTLP_ENDPOINT` for the BFF and the SPA |
| OTel Collector HTTP | 4318 | OTLP/HTTP variant |
| pgweb (profile) | 8081 | http://localhost:8081 — Postgres GUI |
| Jaeger UI (profile) | 16686 | http://localhost:16686 — trace explorer |
| Caddy serve-static (profile) | 4200 | http://localhost:4200/ — production build with per-locale routing (`/fr/`, `/en/`) + smart `/` redirect, per ADR-0019. Run `pnpm exec nx build portal-shell --configuration=production` first or the proxy will 404 everything. |
All ports are overridable via `.env` if the host machine has conflicts.
### Operational tips
- **Persistence** — state lives in named Docker volumes (`apf-portal-postgres-data`, `apf-portal-redis-data`). Survives `docker compose down`. Use `docker compose -f dev.compose.yml down -v` to wipe (also wipes the audit-roles bootstrap, which re-runs on the next fresh boot).
- **Profile symmetry** — `dev.sh down` (and `status`, `logs`, …) always include every profile in scope, so profile-gated services are caught. If you bypass the script and call `docker compose down` directly, you must pass the same `--profile` flags as on `up`, otherwise pgweb and Jaeger keep running silently. Either pass them again, or `export COMPOSE_PROFILES=dbtools,observability` in your shell or `infra/local/.env`.
- **Bootstrap re-run** — the SQL in `local/init/postgres/` only runs on a **fresh** Postgres data volume. To replay after editing the file, `down -v` (loses all dev data) or run the SQL manually with `docker compose exec postgres psql -U portal -d portal_dev -f /docker-entrypoint-initdb.d/01-init.sql`.
- **Logs** — `docker compose -f dev.compose.yml logs -f <service>` to follow a single service. `otel-collector` is the loudest — its `debug` exporter prints every span / metric / log it receives.
- **Image upgrades** — same policy as the runner image (deliberate, not via `:latest`). Renovate's docker-compose manager will surface bumps automatically once the dashboard rule allows them.
### Production parity
This stack is **dev-only**. The corresponding production layout (HA Postgres, Redis Sentinel cluster, OTel Collector with a real backend, secret manager) lives in the future on-prem-infrastructure ADR — see `prod/` placeholder below.
---
## Entra group map — `test-tenant.entra.example.json`
Pure JSON object keyed on Entra security-group GUID (lower-case), valued by an `apf-role-<slug>` slug from the ADR-0025 functional-role catalogue. The BFF loads it at boot through `EntraGroupToRoleResolver` (from `shared-auth`) and uses it on every sign-in to translate the `groups` claim into the 24-entry catalogue's role slugs.
The 24 entries below cover the entire v1 catalogue — including `partenaire`, which ships empty in the test tenant by design but is kept in the schema so a typo or omission fails the parser at boot rather than silently dropping the role.
### Provisioning a real file
```bash
cp infra/test-tenant.entra.example.json infra/test-tenant.entra.json
# Then for each role replace the placeholder GUID with the real one
# from Entra:
# Microsoft Entra admin centre → Groups → <apf-role-*> → Object ID.
# Point the BFF at the file via apps/portal-bff/.env:
# ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
```
The real file (`infra/<env>-tenant.entra.json`) is git-ignored because the group GUIDs are tenant-private — leaking them does not authorize anything by itself, but it does reveal the tenant's internal authorization topology. Each environment (test / preprod / prod) carries its own file; the slugs are stable across environments, the GUIDs are not.
If `ENTRA_GROUP_MAP_PATH` is unset, the resolver runs with an empty map: every user signs in successfully but receives an empty `roles[]` (and consequently no `apf-role-*` UI). The BFF logs a WARN at boot so an operator can spot the missing config; this is a deliberate fail-soft posture so a fresh dev environment is not blocked by an Entra-side dependency.
Validation rules enforced at boot by `parseEntraGroupMap` (in `libs/shared/auth/`):
- keys must look like a GUID (`8-4-4-4-12` hex);
- values must be members of `FUNCTIONAL_ROLES`;
- the same GUID cannot map to two different slugs (case-insensitive).
A malformed file crashes the BFF at startup. The error message names the offending key / value.
---
## Future infra concerns — placeholders
These are listed here so a contributor knows where to expect related files; they don't exist yet.
| File | Purpose | Triggered by |
| --------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `prod/*` | On-prem deployment manifests (k8s, Compose, or whatever the on-prem infra ADR settles on) | The on-prem infrastructure ADR (phase 3b) |
| `runbooks/*.md` | Operational runbooks (incident response, secret rotation, runner upgrade procedure, …) | First incident, or when ops cadence justifies them |