Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Gautier 099393c0e8 feat(spa): proxy /api in dev-server, relative bffApiBaseUrl
Make the SPAs reach the BFF same-origin via the Angular dev-server's
proxy, so the dockerised dev mode (ADR-0030) works when the SPA is
accessed from a remote browser (e.g. http://<vm-ip>:4200/) — and CORS
is bypassed in dev altogether.

- New proxy.conf.js per SPA: /api -> ${BFF_TARGET:-http://localhost:3000}
  (JS form so the env var can swap the target at startup without a
  rebuild).
- project.json serve.options.proxyConfig wired in for both apps.
- environment.ts (shell + admin) bffApiBaseUrl: 'http://localhost:3000/api'
  -> '/api'. Production siblings can still set an absolute origin if
  the SPA and BFF live on different hosts; tracing.ts resolves either
  form against window.location.origin.
- tracing.ts: new URL(env, window.location.origin) — relative bases no
  longer throw, absolute bases keep their own origin.
- dev.compose.yml: BFF_TARGET=http://portal-bff:3000 on portal-shell
  and portal-admin so the proxy hits the BFF container by Compose DNS.
  Native nx serve leaves it unset and falls back to localhost.

OTel exporter URL and cross-SPA links remain absolute — same remote-
browser issue, but not blocking the 'Backend unreachable' path this
PR targets. Out of scope here, follow-up if needed.

Stacked on top of feat/dockerised-dev-mode.
2026-06-01 10:57:46 +02:00
Julien Gautier 7ab43125a1 feat(infra): dockerised full-stack dev mode — apps compose profile (ADR-0030)
CI / scan (pull_request) Successful in 3m46s
CI / commits (pull_request) Successful in 3m46s
CI / check (pull_request) Successful in 4m4s
CI / a11y (pull_request) Successful in 4m25s
Docs site / build (pull_request) Successful in 4m37s
CI / perf (pull_request) Successful in 8m43s
Add an 'apps' Compose profile that runs the three Nx dev servers
(portal-bff, portal-shell, portal-admin) from a shared Dockerfile.dev,
so the whole stack boots with 'dev.sh up apps' and no native Node/pnpm.

- Dockerfile.dev: node:24-bookworm + corepack (pnpm from packageManager
  at runtime), NX_DAEMON=false. No build-time COPY/install.
- dev-entrypoint.sh: BFF runs prisma generate + migrate deploy then
  serves; SPA services go straight to nx serve.
- dev.compose.yml: one-shot apps-deps installs into a shared
  node_modules volume once (apps gate on its completion, no install
  race); repo bind-mounted, node_modules + .nx in named volumes; BFF
  reuses its own .env (required:false) with host URLs overridden to
  Compose service names.
- dev.sh: apps added to ALL_PROFILES + usage.
- Docs: which-mode-when table (setup doc), apps section (infra README),
  CLAUDE.md roll-up. ADR-0030 flipped to accepted.

Dev-only; production images stay with the ADR-0028 Container Registry
work. Additive + profile-gated — native and devcontainer flows
unchanged. Full-boot validation pending on a Docker host (see PR body).
2026-05-29 19:46:13 +02:00
12 changed files with 202 additions and 572 deletions
-5
View File
@@ -35,11 +35,6 @@ pnpm-debug.log*
infra/*-tenant.entra.json infra/*-tenant.entra.json
!infra/*-tenant.entra.example.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
-6
View File
@@ -93,12 +93,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"
-17
View File
@@ -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
@@ -153,32 +153,6 @@ describe('createRateLimitMiddleware', () => {
await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next); await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next);
expect(res.status).not.toHaveBeenCalledWith(429); expect(res.status).not.toHaveBeenCalledWith(429);
}); });
it('keys IPv6 addresses by their /56 prefix so per-host rotation cannot bypass the bucket', async () => {
const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 99 });
const next = jest.fn() as unknown as NextFunction;
// Two addresses inside `2001:db8:abcd:0000::/56` must share a
// bucket — otherwise an attacker swaps the host suffix on every
// retry and the per-IP limit never bites. This is the bypass the
// lib's `ERR_ERL_KEY_GEN_IPV6` boot-time validation refuses to
// ship. (The lib v8 default mask is `/56`, a typical residential
// ISP customer allocation.)
let res = makeRes();
await mw(makeReq({ ip: '2001:db8:abcd::1', path: '/api/x' }), res, next);
expect(res.status).not.toHaveBeenCalledWith(429);
res = makeRes();
await mw(makeReq({ ip: '2001:db8:abcd::ffff', path: '/api/x' }), res, next);
expect(res.status).toHaveBeenCalledWith(429);
// Different `/56` (`2001:db8:abce::/56`) — independent bucket.
// Confirms the truncation does not collapse all IPv6 traffic into
// one global bucket.
res = makeRes();
await mw(makeReq({ ip: '2001:db8:abce::1', path: '/api/x' }), res, next);
expect(res.status).not.toHaveBeenCalledWith(429);
});
}); });
function restore(name: string, value: string | undefined): void { function restore(name: string, value: string | undefined): void {
@@ -1,5 +1,5 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { NextFunction, Request, RequestHandler, Response } from 'express';
import rateLimit, { ipKeyGenerator, type Options } from 'express-rate-limit'; import rateLimit, { type Options } from 'express-rate-limit';
import { errorResponse } from './structured-error.filter'; import { errorResponse } from './structured-error.filter';
/** /**
@@ -79,14 +79,7 @@ export function createRateLimitMiddleware(config: RateLimitConfig): RequestHandl
if (hasSession && sessionId) { if (hasSession && sessionId) {
return `s:${sessionId}`; return `s:${sessionId}`;
} }
// `ipKeyGenerator` normalises the address before keying — most return `ip:${req.ip ?? 'unknown'}`;
// importantly, it truncates IPv6 to its `/56` prefix (the lib v8
// default — a typical residential ISP customer allocation) so an
// attacker can't rotate through the trailing bits of their own
// subnet to escape the per-IP bucket. The lib raises
// `ERR_ERL_KEY_GEN_IPV6` at boot if a custom keyGenerator returns
// `req.ip` verbatim, exactly to prevent that bypass.
return `ip:${ipKeyGenerator(req.ip ?? 'unknown')}`;
}, },
handler: (_req: Request, res: Response, _next: NextFunction, _optionsUsed: Options) => { handler: (_req: Request, res: Response, _next: NextFunction, _optionsUsed: Options) => {
res.status(429).json(errorResponse('rate_limited', 'Too many requests')); res.status(429).json(errorResponse('rate_limited', 'Too many requests'));
-6
View File
@@ -92,12 +92,6 @@
}, },
"development": { "development": {
"buildTarget": "portal-shell:build:development" "buildTarget": "portal-shell:build:development"
},
"https": {
"buildTarget": "portal-shell:build:development",
"ssl": true,
"sslKey": ".secrets/dev-tls.key",
"sslCert": ".secrets/dev-tls.pem"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
+3 -189
View File
@@ -3,7 +3,7 @@
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). 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 | | 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) | | 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) | | 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 | | CI runners convenience script | [`ci-runners.sh`](ci-runners.sh) | See "Convenience script" below |
@@ -11,7 +11,6 @@ Infrastructure-as-code artefacts for the project. Separate from application code
| Env-vars template for the runners | `.env.example` (`.env` is git-ignored) | — | | 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) | | 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) | | 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: Future folders / files that will land here as the corresponding ADRs ship:
@@ -188,9 +187,9 @@ $EDITOR infra/local/.env
Run `./infra/local/dev.sh help` for the full reference. Cheat-sheet: Run `./infra/local/dev.sh help` for the full reference. Cheat-sheet:
| Command | Effect | | Command | Effect |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `./infra/local/dev.sh up` | Core only (postgres + redis + otel-collector) | | `./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 all` | Core + every profile |
| `./infra/local/dev.sh up dbtools` | Core + pgweb | | `./infra/local/dev.sh up dbtools` | Core + pgweb |
| `./infra/local/dev.sh up observability` | Core + Jaeger | | `./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 serve-static` | Core + Caddy serving `dist/.../browser/` per ADR-0019 |
@@ -227,191 +226,6 @@ How it works (see [ADR-0030](../docs/decisions/0030-dockerised-dev-mode.md)):
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). 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 endpoints (defaults)
| Service | Host port | Purpose | | Service | Host port | Purpose |
-26
View File
@@ -35,29 +35,3 @@ OTEL_HTTP_PORT=4318
PGWEB_PORT=8081 PGWEB_PORT=8081
JAEGER_UI_PORT=16686 JAEGER_UI_PORT=16686
SERVE_STATIC_PORT=4200 SERVE_STATIC_PORT=4200
# ---------------------------------------------------------------- Apps (`--profile apps`)
# Two access modes for the SPA dev-servers — pick one. The switch
# happens entirely in this file (compose interpolates the SPA's nx
# serve --configuration AND the BFF's ENTRA_*_REDIRECT_URI from here);
# `apps/portal-bff/.env` stays at its localhost defaults regardless.
# See infra/README.md → "Switching between dev modes".
# === Mode A — Localhost / VSCode port-forwarding (DEFAULT) ===
# Leave the entire Mode B block below commented. The SPA dev-servers
# stay on plain HTTP, the BFF receives the `http://localhost:*`
# redirect URIs already registered in Entra. Browse `http://
# localhost:4200/` from the workstation — VSCode Remote-SSH auto-
# forwards 4200 / 4300 / 3000 from the VM.
# === Mode B — HTTPS via hostname (apf-portal.dev-XX.local) ===
# Required when the SPA is accessed via a hostname (Entra refuses
# `http:` for non-`localhost` redirect URIs — see the team mkcert CA
# setup in infra/README.md). Replace `dev-jg` with YOUR hostname and
# uncomment the five lines.
#
# NX_SERVE_CONFIGURATION=https
# ENTRA_REDIRECT_URI=https://apf-portal.dev-jg.local:4200/api/auth/callback
# ENTRA_POST_LOGOUT_REDIRECT_URI=https://apf-portal.dev-jg.local:4200/
# ENTRA_ADMIN_REDIRECT_URI=https://apf-portal.dev-jg.local:4300/api/admin/auth/callback
# ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=https://apf-portal.dev-jg.local:4300/
+2 -50
View File
@@ -240,21 +240,6 @@ services:
DATABASE_URL: 'postgresql://${POSTGRES_USER:-portal}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-portal_dev}?schema=public' DATABASE_URL: 'postgresql://${POSTGRES_USER:-portal}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-portal_dev}?schema=public'
REDIS_URL: 'redis://default:${REDIS_PASSWORD}@redis:6379/0' REDIS_URL: 'redis://default:${REDIS_PASSWORD}@redis:6379/0'
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318/v1/traces' OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318/v1/traces'
# OIDC redirect URIs. Driven by infra/local/.env so switching
# between the two access modes — `localhost` (default; VSCode
# Remote-SSH port-forwarding) and `https` hostname (the
# ADR-0030 mkcert path) — is a single-file change. Unset →
# localhost defaults; set in infra/local/.env to override with
# an `https://apf-portal.dev-XX.local:…` hostname. See the
# "Switching between dev modes" subsection of infra/README.md.
# Compose `environment` overrides `env_file`, so the BFF's
# own .env stays at localhost (so native `nx serve` works
# untouched) and only `infra/local/.env` carries the
# docker-apps-mode override.
ENTRA_REDIRECT_URI: ${ENTRA_REDIRECT_URI:-http://localhost:3000/api/auth/callback}
ENTRA_POST_LOGOUT_REDIRECT_URI: ${ENTRA_POST_LOGOUT_REDIRECT_URI:-http://localhost:4200/}
ENTRA_ADMIN_REDIRECT_URI: ${ENTRA_ADMIN_REDIRECT_URI:-http://localhost:3000/api/admin/auth/callback}
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI: ${ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI:-http://localhost:4300/}
# Secrets (Entra / session / jwks) come from the BFF's own dev env. # Secrets (Entra / session / jwks) come from the BFF's own dev env.
# `required: false` so SPA-only devs can `up` without it — the BFF # `required: false` so SPA-only devs can `up` without it — the BFF
# then fails its own boot-time config validators with a clear message # then fails its own boot-time config validators with a clear message
@@ -281,27 +266,7 @@ services:
# /api proxy at the BFF container by name (Compose DNS). Native # /api proxy at the BFF container by name (Compose DNS). Native
# `nx serve` leaves BFF_TARGET unset and falls back to localhost. # `nx serve` leaves BFF_TARGET unset and falls back to localhost.
BFF_TARGET: http://portal-bff:3000 BFF_TARGET: http://portal-bff:3000
# `--configuration=${NX_SERVE_CONFIGURATION:-development}` is command: ['pnpm', 'exec', 'nx', 'serve', 'portal-shell', '--host', '0.0.0.0', '--port', '4200']
# interpolated by Compose at YAML parse time from
# `infra/local/.env`. Set `NX_SERVE_CONFIGURATION=https` there to
# serve over TLS — required when accessing via a hostname (Entra
# rejects `http:` for non-`localhost` redirect URIs). See the
# `https` configuration in apps/portal-shell/project.json + the
# "HTTPS dev-server setup" section in infra/README.md for the
# mkcert procedure.
command:
[
'pnpm',
'exec',
'nx',
'serve',
'portal-shell',
'--host',
'0.0.0.0',
'--port',
'4200',
'--configuration=${NX_SERVE_CONFIGURATION:-development}',
]
ports: ports:
- '${SHELL_PORT:-4200}:4200' - '${SHELL_PORT:-4200}:4200'
depends_on: depends_on:
@@ -314,20 +279,7 @@ services:
environment: environment:
# See portal-shell — same proxy target for the admin SPA. # See portal-shell — same proxy target for the admin SPA.
BFF_TARGET: http://portal-bff:3000 BFF_TARGET: http://portal-bff:3000
# See portal-shell for the NX_SERVE_CONFIGURATION rationale. command: ['pnpm', 'exec', 'nx', 'serve', 'portal-admin', '--host', '0.0.0.0', '--port', '4300']
command:
[
'pnpm',
'exec',
'nx',
'serve',
'portal-admin',
'--host',
'0.0.0.0',
'--port',
'4300',
'--configuration=${NX_SERVE_CONFIGURATION:-development}',
]
ports: ports:
- '${ADMIN_PORT:-4300}:4300' - '${ADMIN_PORT:-4300}:4300'
depends_on: depends_on:
+4 -21
View File
@@ -14,23 +14,10 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml" COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml"
# Every profile defined in dev.compose.yml, used as the teardown / # Profiles defined in dev.compose.yml. Keep in sync if a new profile
# status / logs scope so a profile started under a custom flag is # is added.
# always reachable later. Keep in sync if a new profile is added.
ALL_PROFILES=(dbtools observability serve-static apps) ALL_PROFILES=(dbtools observability serve-static apps)
# What `up all` expands to — everything you want running for a full
# dev session. `serve-static` is deliberately excluded:
# - it would collide with `apps` on host port 4200 (both publish the
# SPA there by default — Caddy serves the production build, the
# `apps` profile runs the Angular dev-server);
# - without a prior `nx build --configuration=production`, Caddy
# just 404s every request, so it adds nothing to a comprehensive
# `up all` invocation. Users who actually need it run
# `./infra/local/dev.sh up serve-static` (and `down` still tears
# it down because it's in ALL_PROFILES above).
UP_ALL_PROFILES=(dbtools observability apps)
# Build "--profile p1 --profile p2 …" as separate arguments. # Build "--profile p1 --profile p2 …" as separate arguments.
build_all_profile_flags() { build_all_profile_flags() {
local p local p
@@ -69,11 +56,7 @@ Commands:
up [target...] Bring up the stack. up [target...] Bring up the stack.
Targets: Targets:
(none) core only (postgres + redis + otel-collector) (none) core only (postgres + redis + otel-collector)
all core + dbtools + observability + apps — all core + all profiles
the full dev stack. serve-static is
EXCLUDED because it collides with apps
on port 4200; run it explicitly when
you actually want to serve a prod build.
dbtools core + pgweb dbtools core + pgweb
observability core + jaeger observability core + jaeger
serve-static core + caddy (production-build reverse proxy) serve-static core + caddy (production-build reverse proxy)
@@ -136,7 +119,7 @@ case "$cmd" in
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
dc_core up -d dc_core up -d
elif [[ "$1" == "all" ]]; then elif [[ "$1" == "all" ]]; then
up_with_profiles "${UP_ALL_PROFILES[@]}" dc_all up -d
else else
up_with_profiles "$@" up_with_profiles "$@"
fi fi
-1
View File
@@ -45,7 +45,6 @@
"axios@<1.15.2": ">=1.15.2", "axios@<1.15.2": ">=1.15.2",
"@angular-devkit/core>ajv@<8.18.0": ">=8.18.0", "@angular-devkit/core>ajv@<8.18.0": ">=8.18.0",
"brace-expansion@<5.0.6": ">=5.0.6", "brace-expansion@<5.0.6": ">=5.0.6",
"dompurify@<3.4.5": ">=3.4.5",
"esbuild@<0.25.0": ">=0.25.0", "esbuild@<0.25.0": ">=0.25.0",
"follow-redirects@<1.16.0": ">=1.16.0", "follow-redirects@<1.16.0": ">=1.16.0",
"ip-address@<10.1.1": ">=10.1.1", "ip-address@<10.1.1": ">=10.1.1",
+171 -196
View File
File diff suppressed because it is too large Load Diff