From c566dfb64d5a50f10607af6525e9c653ab0d9e6d Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Fri, 7 Aug 2026 12:10:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20retire=20fakeidp=20=E2=80=94?= =?UTF-8?q?=20Keycloak=20is=20the=20IdP=20everywhere=20(#2198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the fakeidp OIDC test double (crate, helm subchart, umbrella wiring, compose service, CI component) and migrates its last consumers: - functional-ci (k3s smoke): in-stack Keycloak subchart on the shared MariaDB, realm generated by insight-seed-realm and applied via the keycloak-config-cli hook; workflow installs uv and runs keycloak-realm. - gateway e2e rigs (main + downstream-verify): realm-importing Keycloak container; the pytest client drives the real login form. Both suites pass locally (11 + 6). - seed/stand tooling: AUTH_MODE/--auth-mode removed; the whole roster gets login rows keyed on persona uuid; IDP_SOURCE_TYPE defaults to keycloak. - docs: DESIGNs updated, ADR-0001/0002/0003 get dated status notes; ADR-0003 registered in the cfs artifact registry (fixes pre-existing dangling refs). Completes the fakeidp retirement tracked in #2198 (ADR-0003). Co-Authored-By: Claude Fable 5 Signed-off-by: Anton Zelenov --- .cf-studio/config/artifacts.toml | 5 + .claude/skills/file-bug-insight/SKILL.md | 2 +- .env.compose.example | 2 +- .github/workflows/functional-k3s.yml | 18 +- .github/workflows/gateway.yml | 14 +- CONTRIBUTING.md | 84 +- charts/insight/Chart.lock | 7 +- charts/insight/Chart.yaml | 10 +- charts/insight/templates/NOTES.txt | 8 - charts/insight/templates/_helpers.tpl | 13 +- charts/insight/templates/secrets.yaml | 4 +- charts/insight/values.yaml | 17 +- deploy/HELM_DEPLOY.md | 12 +- deploy/compose/authenticator-fullauth.yaml | 2 +- deploy/compose/insight-init.sh | 4 +- deploy/compose/keycloak/README.md | 1 - deploy/gitops/.gitignore | 5 +- deploy/gitops/Makefile | 2 +- .../environments/functional-ci/values.yaml | 61 +- .../local/inventory.yaml.template | 2 +- .../environments/local/values.yaml.template | 20 +- deploy/gitops/scripts/compose-app-secrets.sh | 2 +- deploy/gitops/secrets-store.yaml.template | 8 +- dev-compose.sh | 29 +- docker-compose.yml | 71 +- .../backend/authenticator/DESIGN.md | 4 +- .../ADR/0001-per-environment-idp-selection.md | 3 + .../ADR/0002-real-idp-on-deployed-stands.md | 4 + .../ADR/0003-keycloak-identity-broker.md | 7 +- docs/components/deployment/specs/DESIGN.md | 43 +- scripts/ci/components.py | 18 +- src/backend/Cargo.lock | 34 +- src/backend/Cargo.toml | 7 +- src/backend/services/analytics/Dockerfile | 4 - src/backend/services/authenticator/Dockerfile | 3 - .../authenticator/config/insight.yaml | 4 +- .../services/authenticator/src/config.rs | 13 +- .../services/authenticator/src/identity.rs | 3 +- .../services/authenticator/src/oidc.rs | 18 +- .../services/authenticator/tests/common/kc.rs | 14 +- .../authenticator/tests/e2e_refresher.rs | 4 +- .../services/authenticator/tests/run-e2e.sh | 8 +- src/backend/services/fakeidp/Cargo.toml | 52 -- src/backend/services/fakeidp/Dockerfile | 79 -- src/backend/services/fakeidp/README.md | 156 ---- src/backend/services/fakeidp/helm/Chart.yaml | 6 - .../fakeidp/helm/templates/_helpers.tpl | 13 - .../fakeidp/helm/templates/deployment.yaml | 50 -- .../fakeidp/helm/templates/ingress.yaml | 38 - .../fakeidp/helm/templates/service.yaml | 15 - src/backend/services/fakeidp/helm/values.yaml | 42 - src/backend/services/fakeidp/src/lib.rs | 807 ------------------ src/backend/services/fakeidp/src/main.rs | 9 - src/backend/services/fakeidp/tests/boot.rs | 92 -- src/backend/services/fakeidp/tests/flow.rs | 699 --------------- src/backend/services/fakeidp/users.yaml | 38 - src/backend/services/gateway/tests/.gitignore | 2 + .../services/gateway/tests/conftest.py | 123 ++- .../gateway/tests/docker-compose.e2e.yml | 50 +- .../gateway/tests/downstream-verify/README.md | 11 +- .../tests/downstream-verify/conftest.py | 110 ++- .../downstream-verify/docker-compose.e2e.yml | 55 +- .../tests/downstream-verify/run-e2e.sh | 7 +- src/backend/services/gateway/tests/run-e2e.sh | 3 +- .../services/identity-resolution/Dockerfile | 2 - src/ingestion/tests/e2e/lib/api_coverage.py | 5 +- src/ingestion/tools/seed/PROFILE.md | 4 +- .../tools/seed/insight_seed/identity.py | 9 +- .../tools/seed/insight_seed/manifest.py | 9 +- .../tools/seed/insight_seed/profiles.py | 39 +- src/ingestion/tools/seed/seed-job.yaml.tpl | 5 +- src/ingestion/tools/seed/seed-stand.sh | 25 +- .../tools/seed/tests/test_identity.py | 46 +- tests/lib/insight_stand/manifest.py | 6 +- tests/lib/insight_stand/session.py | 2 +- tests/stand/api/identity/test_internal.py | 23 +- 76 files changed, 606 insertions(+), 2620 deletions(-) delete mode 100644 src/backend/services/fakeidp/Cargo.toml delete mode 100644 src/backend/services/fakeidp/Dockerfile delete mode 100644 src/backend/services/fakeidp/README.md delete mode 100644 src/backend/services/fakeidp/helm/Chart.yaml delete mode 100644 src/backend/services/fakeidp/helm/templates/_helpers.tpl delete mode 100644 src/backend/services/fakeidp/helm/templates/deployment.yaml delete mode 100644 src/backend/services/fakeidp/helm/templates/ingress.yaml delete mode 100644 src/backend/services/fakeidp/helm/templates/service.yaml delete mode 100644 src/backend/services/fakeidp/helm/values.yaml delete mode 100644 src/backend/services/fakeidp/src/lib.rs delete mode 100644 src/backend/services/fakeidp/src/main.rs delete mode 100644 src/backend/services/fakeidp/tests/boot.rs delete mode 100644 src/backend/services/fakeidp/tests/flow.rs delete mode 100644 src/backend/services/fakeidp/users.yaml diff --git a/.cf-studio/config/artifacts.toml b/.cf-studio/config/artifacts.toml index 804deac6b..163e49768 100644 --- a/.cf-studio/config/artifacts.toml +++ b/.cf-studio/config/artifacts.toml @@ -602,6 +602,11 @@ kind = "ADR" path = "docs/components/backend/authenticator/specs/ADR/0002-real-idp-on-deployed-stands.md" name = "ADR-0002: Real IdP on Deployed Stands (Pre-Provisioned Keycloak)" +[[systems.artifacts]] +kind = "ADR" +path = "docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md" +name = "ADR-0003: Keycloak as the Identity Broker (Configured as Code)" + [[systems.artifacts]] kind = "DESIGN" path = "docs/components/backend/gateway/DESIGN.md" diff --git a/.claude/skills/file-bug-insight/SKILL.md b/.claude/skills/file-bug-insight/SKILL.md index 265218d9c..0b7929f34 100644 --- a/.claude/skills/file-bug-insight/SKILL.md +++ b/.claude/skills/file-bug-insight/SKILL.md @@ -32,7 +32,7 @@ Each of these owns a slice of the work. Some are still being built out here, so | Skill | Owns | Reach for it when | |---|---|---| | `playwright-cli` | the browser command surface — snapshots, refs, clicks, screenshots, console, network | exploring a stand or reproducing any UI defect | -| `drive-ui` | getting an *authenticated* browser on any stand — fakeidp and the `DEV_USER_EMAIL` seed locally, a passkey attach on a remote one — plus the routes and the evidence set | any UI defect, local or remote | +| `drive-ui` | getting an *authenticated* browser on any stand — the Keycloak realm login and the `DEV_USER_EMAIL` seed locally, a passkey attach on a remote one — plus the routes and the evidence set | any UI defect, local or remote | | `metric-parity` | the full bronze → silver → gold walk | collecting the same query at every layer | | `release-verify` | install and seed health | settling "product bug, or empty instance?" | diff --git a/.env.compose.example b/.env.compose.example index 7b93d313f..579693272 100644 --- a/.env.compose.example +++ b/.env.compose.example @@ -40,7 +40,7 @@ FRONTEND_IMAGE= # Auth always runs via Keycloak: a real Keycloak container (:8085) with a # login form + the custom claims, against a realm generated per run. The # gateway ENFORCES the JWT. See deploy/compose/keycloak/README.md. -# (fakeidp is retired; a lingering AUTH_MODE= line here is ignored.) +# (A lingering AUTH_MODE= line here is ignored.) # ── Backend image overrides (per service) ───────────────────────────── # When set, that service pulls the named image instead of building diff --git a/.github/workflows/functional-k3s.yml b/.github/workflows/functional-k3s.yml index cb081f07f..444eef802 100644 --- a/.github/workflows/functional-k3s.yml +++ b/.github/workflows/functional-k3s.yml @@ -61,6 +61,11 @@ jobs: - name: Install Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + # The keycloak-realm Makefile target generates the roster realm with the + # seed package's insight-seed-realm entry point, which runs under uv. + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Install gitops CLI tools run: | set -euo pipefail @@ -182,17 +187,28 @@ jobs: make -C deploy/gitops system ENV="${GITOPS_ENV}" KUBE_CTX="${KUBE_CONTEXT}" make -C deploy/gitops system-status ENV="${GITOPS_ENV}" KUBE_CTX="${KUBE_CONTEXT}" + # Generates the Keycloak roster realm JSON into the env realms dir and + # applies the insight-keycloak-config admin Secret; deploy-app's chained + # keycloak-broker-realms target packs the realm into the ConfigMap the + # keycloak-config-cli hook Job applies. + - name: Generate Keycloak realm + run: | + set -euo pipefail + make -C deploy/gitops keycloak-realm ENV="${GITOPS_ENV}" KUBE_CTX="${KUBE_CONTEXT}" + - name: Deploy Insight via gitops Makefile run: | set -euo pipefail helm dependency update charts/insight INSIGHT_VERSION="$(yq -r '.version' charts/insight/Chart.yaml)" + # 10m: the atomic wait now also covers Keycloak's first boot (DB + # schema init) and the post-install keycloak-config-cli hook Job. make -C deploy/gitops deploy-app \ ENV="${GITOPS_ENV}" \ KUBE_CTX="${KUBE_CONTEXT}" \ CHART=../../charts/insight \ INSIGHT_VERSION="${INSIGHT_VERSION}" \ - TIMEOUT=5m + TIMEOUT=10m - name: Verify Insight workloads run: | diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index 94d3f4496..dda3241a0 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -13,6 +13,8 @@ on: paths: - "src/backend/tools/routegen/**" - "src/backend/services/gateway/**" + # The e2e Keycloak imports the generated roster realm. + - "src/ingestion/tools/seed/insight_seed/keycloak_realm.py" - ".github/workflows/gateway.yml" workflow_dispatch: @@ -91,9 +93,10 @@ jobs: -v "$PWD/rendered-routes.yaml":/etc/gateway/routes.yaml:ro \ insight-gateway:ci - # Full edge e2e: real authenticator + fakeidp behind the gateway (the five - # NGINX_BFF step-05 scenarios). No host Rust toolchain -- routegen and every - # service image are built inside Docker; the host only needs python + pytest. + # Full edge e2e: real authenticator + a realm-importing Keycloak behind the + # gateway (the five NGINX_BFF step-05 scenarios). No host Rust toolchain -- + # routegen and every service image are built inside Docker; the host only + # needs python + pytest (+ uv for the realm generator). e2e: name: e2e (5 scenarios) runs-on: ubuntu-latest @@ -106,6 +109,11 @@ jobs: with: python-version: "3.12" + # conftest.py generates the Keycloak import realm through + # `uv run --project`: the generator is a console script of the seed + # package, and uv resolves and installs it on first use. + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Install pytest run: python -m pip install pytest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index caeae67a7..8e3a4f2a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ files under `docs/components//specs/`. - [First-run wizard + re-runs](#first-run-wizard--re-runs) - [External MariaDB / ClickHouse](#external-mariadb--clickhouse) - [Frontend modes](#frontend-modes) - - [Local dev auth backend (fakeidp / Keycloak)](#local-dev-auth-backend-fakeidp--keycloak) + - [Local dev auth (Keycloak)](#local-dev-auth-keycloak) - [Backend image fallback (ghcr)](#backend-image-fallback-ghcr) - [Settings reference (`.env.compose`)](#settings-reference-envcompose) 6. [Daily workflow](#daily-workflow) @@ -30,7 +30,7 @@ files under `docs/components//specs/`. 7. [Seeding](#seeding) - [Compose](#compose) - [Kubernetes](#kubernetes) -8. [Dev auth chain (fakeidp)](#dev-auth-chain-fakeidp) +8. [Dev auth chain (Keycloak)](#dev-auth-chain-keycloak) 9. [Troubleshooting](#troubleshooting) 10. [Code style and reviews](#code-style-and-reviews) @@ -53,7 +53,7 @@ First-run wizard prompts (Enter accepts defaults): | --- | --- | --- | | Use local MariaDB? | Y | Compose starts mariadb on :3306 | | Use local ClickHouse? | Y | Compose starts clickhouse on :8123 | -| `DEV_USER_EMAIL` | `dev@company.nonpresent` | FakeIdP login and dev-team lead in the seed roster | +| `DEV_USER_EMAIL` | `dev@company.nonpresent` | Keycloak login (realm roster anchor) and dev-team lead in the seed roster | | Frontend mode | `1` (ghcr) | Pulls the published `insight-frontend:latest` image | Then the script builds host artefacts, brings up the stack, auto-seeds @@ -65,7 +65,7 @@ Open . `dev@company.nonpresent` leads the dev team; CEO sees the whole org tree. To use CEO more set email to `email_ceo@company.nonpresent`. > **Stuck after pulling an update?** The compose stack runs full auth -> (fakeidp → authenticator → nginx gateway → downstream JWT verification; +> (Keycloak → authenticator → nginx gateway → downstream JWT verification; > no `auth_disabled`). If a stale local config trips it up, wipe and > regenerate: `rm -f .env.compose && ./dev-compose.sh up` re-runs the > first-run wizard, and `./dev-compose.sh prune` additionally clears the @@ -180,11 +180,11 @@ cp environments/local/inventory.yaml.template environments/local/inventory.yaml cp environments/local/values.yaml.template environments/local/values.yaml # Edit: # global.tenantDefaultId: # required for external DBs with seeded persons -# fakeidp.deploy: true # local sandbox IdP; set false + point +# keycloak.deploy: true # in-stack local IdP; set false + point # authenticator.oidc.issuerUrl: # authenticator.oidc.* at a real IdP # .host / .port # only when .deploy=false # The `__INGRESS_LB_IP__` placeholders (authenticator.oidc.issuerUrl, -# fakeidp.issuer) must be replaced with your ingress-nginx LoadBalancer IP +# keycloak.hostname) must be replaced with your ingress-nginx LoadBalancer IP # (`kubectl -n ingress-nginx get svc ingress-nginx-controller`). # 3. Cleartext secret store (read by `make seal`, never committed). @@ -217,7 +217,7 @@ every Deployment is Ready before the chain returns. │ Backend │ │ gateway (nginx :8080) analytics (Rust :8081) │ │ identity-resolution (Rust :8086) │ -│ authenticator (Rust :8083/:8093) fakeidp (Rust :8084, dev-only) │ +│ authenticator (Rust :8083/:8093) keycloak (:8085, dev IdP) │ ├──────────────────────────────────────────────────────────────────────┤ │ Infra │ │ MariaDB :3306 ClickHouse :8123/:9000 Redis :6379 Redpanda :19092…│ @@ -285,8 +285,7 @@ wizard. To use it, hand-edit `FRONTEND_MODE=built` in `.env.compose`, ### Local dev auth (Keycloak) Auth always runs via Keycloak: a real Keycloak container with an actual login -form, exercising the genuine OIDC code path. (The old `fakeidp` mode and the -`AUTH_MODE` / `--auth` switches are retired.) +form, exercising the genuine OIDC code path. The authenticator logs in server-side against the generated realm's `insight-authenticator` confidential client; the SPA stays cookie/BFF (no @@ -399,7 +398,7 @@ watchexec wants, and `useradd -m` ensures `appuser` has a usable ```bash # Tail logs -docker compose logs -f gateway authenticator analytics identity-resolution fakeidp +docker compose logs -f gateway authenticator analytics identity-resolution keycloak # Inspect databases docker compose exec mariadb mariadb -uinsight -pinsight-local identity @@ -422,16 +421,16 @@ they're slow to re-pull). After prune, next `up` re-runs the wizard. ### Point the authenticator at a real IdP Auth is **always on** — there is no bypass. Local dev logs in against the -in-repo `fakeidp` OIDC provider by default. The authenticator is -IdP-agnostic, so switching to a real IdP (Entra, Keycloak, …) is a -config change, not a mode flip: +bundled Keycloak (realm generated from the seed roster) by default. The +authenticator is IdP-agnostic, so switching to a real IdP (Entra, an +external Keycloak, …) is a config change, not a mode flip: - **Compose** — set `AUTHENTICATOR_OIDC_ISSUER` (plus `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` and `AUTHENTICATOR_REDIRECT_URI`) in `.env.compose` - and bounce the `authenticator`. Leaving them unset falls back to - `http://fakeidp:8084`. + and bounce the `authenticator`. Leaving them unset keeps the default: + the bundled Keycloak realm at `http://:8085/kc/realms/insight`. - **K8s** — set `authenticator.oidc.issuerUrl` (+ `clientId` / - `redirectUri`) in the values overlay and set `fakeidp.deploy: false`. + `redirectUri`) in the values overlay and set `keycloak.deploy: false`. > **redirect/issuer: local uses `localhost`, remote needs a real host.** On local > k8s `issuerUrl` is the ingress LB IP (e.g. `http://192.168.139.2/kc/realms/insight`) @@ -449,8 +448,8 @@ config change, not a mode flip: > svc/insight-gateway 8080:80` and use `http://localhost:8080`. See ADR -[`docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md`](docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md) -for the per-environment IdP selection rationale. +[`docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md`](docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md) +for the per-environment IdP rationale (Keycloak as the issuer everywhere). --- @@ -462,7 +461,7 @@ README documents the layout and the uv / ruff / mypy setup. Both deploy paths install the package and run the same program (`insight-seed `); only how it is invoked differs. Generating the compose Keycloak realm also runs from that package (`insight-seed-realm`, via `uv run`), so `uv` is a prerequisite for -`./dev-compose.sh up --auth=keycloak`. +`./dev-compose.sh up`. **Identity content (after `seed identity`):** CEO, your `DEV_USER_EMAIL` person (leads the dev team), 4 team leads (dev / @@ -529,21 +528,22 @@ rows (the silver step TRUNCATEs before writing), both stop the run. --- -## Dev auth chain (fakeidp) +## Dev auth chain (Keycloak) Auth is **always on** (NGINX_BFF EPIC #1583) — there is no no-auth mode. Every request that reaches a backend carries an ES256 gateway JWT that the `gateway` (nginx / OpenResty) injects after the `authenticator` -confirms a valid session. Local dev logs in against `fakeidp`, an -in-repo dev-only OIDC provider, so the real login code path runs with no -external IdP. +confirms a valid session. Local dev logs in against the bundled Keycloak +container (realm generated from the seed roster on every `up`), so the +real login code path runs with no external IdP. ```text 1. Browser → GET /auth/login on the gateway (:8080). The authenticator starts an OIDC authorization-code + PKCE flow and 302s to - fakeidp's /authorize. -2. fakeidp → no login screen: mints a one-time code for the default - user (DEV_USER_EMAIL) and 302s back to /auth/callback. + Keycloak's /authorize (a real login form). +2. Keycloak → the user signs in as any seeded persona (password + `insight-dev`); Keycloak mints a one-time code and 302s + back to /auth/callback. 3. Gateway → /auth/callback → authenticator exchanges the code for tokens, resolves the person in identity, opens an opaque session in Redis, and sets the `__Host-sid` cookie. @@ -567,29 +567,33 @@ things a browser needs that curl doesn't are handled automatically: `http://localhost:3000/auth/callback` (the Vite origin, which proxies `/auth` + `/api` to the gateway) — never the authenticator's own `:8083`, where the cookie would strand. -- **The fakeidp issuer is a host IP, not a hostname.** `./dev-compose.sh up` - auto-detects your host IP and sets `FAKEIDP_ISSUER` + `AUTHENTICATOR_OIDC_ISSUER` - to `http://:8084`. A hostname (`fakeidp:8084`) gets HTTPS-upgraded by - the browser and fails (fakeidp is http-only); `localhost` means the container - itself. An IP literal is reached un-upgraded by the browser and by the - containers alike. (curl/e2e flows run inside the compose network, so when no - issuer is set they fall back to `fakeidp:8084` and don't need this.) +- **The Keycloak issuer is a host IP, not a hostname.** `./dev-compose.sh up` + auto-detects your host IP and sets `KEYCLOAK_HOSTNAME` + + `AUTHENTICATOR_OIDC_ISSUER` to `http://:8085/kc/realms/insight`. + A hostname (`keycloak:8085`) only resolves inside the compose network, and + `localhost` means the container itself — an IP literal is reachable by the + browser and by the containers alike, so the id_token `iss` validates on + both sides of the flow. So a dev call succeeds when: -- The stack is up with `fakeidp` (default profile) and the authenticator +- The stack is up with the `keycloak` container (profile `auth-keycloak`, + started by `up`) and the authenticator dev signing key + authn-tls cert exist (generated by `dev-compose.sh up`). - A row in `persons` has `value_type='email'` and `value_id` matching - `DEV_USER_EMAIL` (run `./dev-compose.sh seed identity` — fakeidp's - default login resolves to that seeded person). + the login email (run `./dev-compose.sh seed identity` — the Keycloak + realm and the identity seed are generated from the same roster, so + every realm user resolves to a seeded person). - The gateway's `routes.yaml` proxies `/api/{prefix}` to the right upstream (`deploy/compose/gateway/routes.yaml`). To drive it from the host with `curl` (or a browser), start at `http://localhost:8080/auth/login` and follow the redirects with a cookie -jar so the `__Host-sid` cookie is captured; subsequent `/api/*` calls -reuse that session. `fakeidp` itself exposes a copy-paste code+PKCE flow -in `src/backend/services/fakeidp/README.md` for exercising it directly. +jar so the `__Host-sid` cookie is captured (the Keycloak form takes a +seeded email + the `insight-dev` password); subsequent `/api/*` calls +reuse that session. See +[`deploy/compose/keycloak/README.md`](deploy/compose/keycloak/README.md) +for login creds, the admin console, and the custom-claims contract. --- @@ -618,7 +622,7 @@ plugin needs the authn-tls discovery cert re-run `up` (or `prune` then `up`) so the key/cert are regenerated. **Login returns 403 / "person not found".** -`fakeidp`'s default login identity (`DEV_USER_EMAIL`) must resolve +The email you log in with (e.g. `DEV_USER_EMAIL`) must resolve to a seeded person in identity. Run `./dev-compose.sh seed identity` first — an unknown person is denied. diff --git a/charts/insight/Chart.lock b/charts/insight/Chart.lock index 1bdab458a..22fba466c 100644 --- a/charts/insight/Chart.lock +++ b/charts/insight/Chart.lock @@ -5,9 +5,6 @@ dependencies: - name: insight-authenticator repository: file://../../src/backend/services/authenticator/helm version: 0.1.0 -- name: insight-fakeidp - repository: file://../../src/backend/services/fakeidp/helm - version: 0.1.0 - name: insight-keycloak repository: file://../../src/backend/services/keycloak/helm version: 0.1.0 @@ -20,5 +17,5 @@ dependencies: - name: insight-frontend repository: file://../../src/frontend/helm version: 0.1.0 -digest: sha256:1dba4d008c5a87d45bc85d6bda997a35e5ea691390c88cbba7a068571fdc4eb6 -generated: "2026-07-30T12:32:50.695289+08:00" +digest: sha256:784e86e2d61578466e3c8bd82ff1821b2dc39c40faea318269320f0519f323f6 +generated: "2026-08-07T11:00:44.602105+08:00" diff --git a/charts/insight/Chart.yaml b/charts/insight/Chart.yaml index 634e2baa4..d6b214bd6 100644 --- a/charts/insight/Chart.yaml +++ b/charts/insight/Chart.yaml @@ -5,7 +5,7 @@ ## Emits almost nothing on its own — it orchestrates subcharts. ## ## What is INCLUDED here: -## - Edge / auth: gateway (nginx+auth), authenticator, fakeidp (dev only) +## - Edge / auth: gateway (nginx+auth), authenticator, keycloak ## - Application: analytics, identity-resolution, frontend ## ## What is NOT included (deployed separately): @@ -65,8 +65,7 @@ dependencies: # single entrance: auth_request -> authenticator, gateway-JWT injection, and # fan-out to the app services + SPA. The `authenticator` runs OIDC login + # Redis sessions + the cookie->ES256 gateway-JWT exchange (with an authn-tls - # sidecar so downstream verifiers resolve the JWKS over https). `fakeidp` is - # the dev/e2e OIDC provider (local only; never a real environment). + # sidecar so downstream verifiers resolve the JWKS over https). - name: insight-gateway alias: gateway version: "0.1.0" @@ -75,11 +74,6 @@ dependencies: alias: authenticator version: "0.1.0" repository: "file://../../src/backend/services/authenticator/helm" - - name: insight-fakeidp - alias: fakeidp - version: "0.1.0" - repository: "file://../../src/backend/services/fakeidp/helm" - condition: fakeidp.deploy # keycloak — the stack's identity broker (ADR-0003), MariaDB-backed. - name: insight-keycloak alias: keycloak diff --git a/charts/insight/templates/NOTES.txt b/charts/insight/templates/NOTES.txt index 884b5643f..fc3eb7526 100644 --- a/charts/insight/templates/NOTES.txt +++ b/charts/insight/templates/NOTES.txt @@ -19,9 +19,6 @@ Application services (always-on): ✓ Authenticator http://{{ include "insight.authenticator.host" . }}:8083 ✓ Analytics http://{{ include "insight.analytics.host" . }}:8081 ✓ Frontend http://{{ include "insight.frontend.host" . }}:80 -{{- if .Values.fakeidp.deploy }} - ⚠ fakeidp (dev IdP) http://{{ include "insight.fakeidp.host" . }}:8084 (DEV/E2E ONLY — never production) -{{- end }} {{- if .Values.identityResolution.deploy }} ✓ Identity http://{{ include "insight.identityResolution.host" . }}:8082 (deployed) {{- else }} @@ -45,11 +42,6 @@ Next steps: 3. Docs: https://github.com/constructorfabric/insight -{{- if .Values.fakeidp.deploy }} -⚠ WARNING: fakeidp is DEPLOYED — this is a DEV/E2E-only OIDC provider and must - NEVER run in a real environment. Set fakeidp.deploy=false and point - authenticator.oidc.issuerUrl at your real IdP for anything else. -{{- end }} {{- if and .Values.ingestion.templates.enabled (not .Values.ingestion.reconcile.argoInstanceId) }} ⚠ WARNING: ingestion.templates.enabled=true but ingestion.reconcile.argoInstanceId diff --git a/charts/insight/templates/_helpers.tpl b/charts/insight/templates/_helpers.tpl index c1cd64c76..a3623a527 100644 --- a/charts/insight/templates/_helpers.tpl +++ b/charts/insight/templates/_helpers.tpl @@ -133,7 +133,6 @@ App services are mandatory umbrella components — no deploy flag. {{- define "insight.analytics.host" -}}{{- printf "%s-analytics" .Release.Name -}}{{- end -}} {{- define "insight.identityResolution.host" -}}{{- printf "%s-identity-resolution" .Release.Name -}}{{- end -}} {{- define "insight.frontend.host" -}}{{- printf "%s-frontend" .Release.Name -}}{{- end -}} -{{- define "insight.fakeidp.host" -}}{{- printf "%s-fakeidp" .Release.Name -}}{{- end -}} {{/* ============================================================================== @@ -173,17 +172,7 @@ Invoked from NOTES.txt so they fire on every install. {{- $auth := default dict .Values.authenticator -}} {{- $aoidc := default dict $auth.oidc -}} {{- if or (not $aoidc.issuerUrl) (not $aoidc.redirectUri) -}} - {{- fail "authenticator.oidc: issuerUrl (the IdP) and redirectUri (the browser callback) are REQUIRED — auth is always on (no auth_disabled). For local, point issuerUrl at the fakeidp Service FQDN and set fakeidp.deploy=true." -}} - {{- end -}} - - {{- /* fakeidp is dev/e2e only. Refuse to arm it as a real IdP: if - fakeidp.deploy=true, the authenticator MUST point at it (issuerUrl == - fakeidp.issuer), and a real environment must never set deploy=true. */ -}} - {{- $fake := default dict .Values.fakeidp -}} - {{- if $fake.deploy -}} - {{- if ne (toString $aoidc.issuerUrl) (toString $fake.issuer) -}} - {{- fail (printf "fakeidp.deploy=true but authenticator.oidc.issuerUrl (%q) != fakeidp.issuer (%q) — they must be identical (the authenticator validates the id_token `iss` against its configured IdP)." $aoidc.issuerUrl $fake.issuer) -}} - {{- end -}} + {{- fail "authenticator.oidc: issuerUrl (the IdP) and redirectUri (the browser callback) are REQUIRED — auth is always on (no auth_disabled). For local, point issuerUrl at the in-stack Keycloak realm URL and set keycloak.deploy=true." -}} {{- end -}} {{- /* The authenticator's login-bootstrap resolve diff --git a/charts/insight/templates/secrets.yaml b/charts/insight/templates/secrets.yaml index c6367d3ed..dd66779aa 100644 --- a/charts/insight/templates/secrets.yaml +++ b/charts/insight/templates/secrets.yaml @@ -174,7 +174,7 @@ stringData: # APP__gears__authenticator__config__* env vars, overriding the mounted config # ConfigMap. Redis reuses the auto-generated password; the gateway_issuer is the # authn-tls discovery FQDN (must equal the token `iss` and the cert SAN); the -# idp.* + redirect_uri come from `.Values.authenticator.oidc.*` (fakeidp in +# idp.* + redirect_uri come from `.Values.authenticator.oidc.*` (Keycloak in # local). The signing keys are a SEPARATE mounted Secret (not here). apiVersion: v1 kind: Secret @@ -194,7 +194,7 @@ stringData: # Discovery/JWKS issuer served by the authn-tls sidecar. Downstream verifiers # trust this over https; it is also the minted token `iss`. APP__gears__authenticator__config__gateway_issuer: {{ printf "https://%s-authenticator.%s.svc.cluster.local:8443" .Release.Name .Release.Namespace | quote }} - # OIDC upstream (the real IdP; fakeidp for local). issuer_url must be + # OIDC upstream (the real IdP; Keycloak for local). issuer_url must be # in-cluster reachable AND equal the id_token `iss`. APP__gears__authenticator__config__idp__issuer_url: {{ tpl (required "authenticator.oidc.issuerUrl is required" .Values.authenticator.oidc.issuerUrl) . | quote }} APP__gears__authenticator__config__idp__client_id: {{ .Values.authenticator.oidc.clientId | default "insight-authenticator" | quote }} diff --git a/charts/insight/values.yaml b/charts/insight/values.yaml index dbe84acc1..619508779 100644 --- a/charts/insight/values.yaml +++ b/charts/insight/values.yaml @@ -370,9 +370,9 @@ authenticator: name: local-ca kind: ClusterIssuer # OIDC upstream + browser callback, folded into insight-authenticator-config. - # Values may be Helm templates (rendered with `tpl`); local points at fakeidp. + # Values may be Helm templates (rendered with `tpl`); local points at Keycloak. oidc: - issuerUrl: "" # the IdP issuer (local: the in-cluster fakeidp FQDN) + issuerUrl: "" # the IdP issuer (local: the in-stack Keycloak realm URL) clientId: "insight-authenticator" clientSecret: "" redirectUri: "" # browser-facing callback, through the gateway edge @@ -387,19 +387,6 @@ authenticator: # minted for that person instead of the authenticated one. Dev/demo # environments ONLY — MUST stay false anywhere real users log in. overrideEnabled: false -# ─── fakeidp (dev/e2e OIDC provider — NEVER a real environment) ───────────── -fakeidp: - deploy: false - image: - repository: ghcr.io/constructorfabric/insight-fakeidp - tag: "" # MUST be set (e.g. "0.1.0") - pullPolicy: IfNotPresent - # In-cluster URL the authenticator validates the id_token `iss` against. Set - # to the fakeidp Service FQDN (a Helm template, rendered with `tpl`). MUST - # equal `authenticator.oidc.issuerUrl`. - issuer: "" - defaultAudience: "insight-authenticator" - devUserEmail: "dev@company.nonpresent" # ─── keycloak ──────────────────────────────────────────────────────────────── # The stack's Keycloak (identity broker, ADR-0003): start, MariaDB-backed, # admin from a Secret; realm content via keycloakConfig only. diff --git a/deploy/HELM_DEPLOY.md b/deploy/HELM_DEPLOY.md index a2b3da6b2..a26793d32 100644 --- a/deploy/HELM_DEPLOY.md +++ b/deploy/HELM_DEPLOY.md @@ -45,7 +45,7 @@ Insight reads engineering and collaboration data from your tools (Jira, Slack, G - **Identity Resolution** (`insight-identity-resolution`, alias `identityResolution`) — resolves people and org data from MariaDB. `identityResolution.deploy: true` is the chart default and effectively required: the Authenticator's login-bootstrap person lookup only exists on this service (constructorfabric/insight#1960), so the chart's `insight.validate` render-time check refuses to install with it off. - **Frontend** (`insight-frontend`, alias `frontend`) — the web UI (dashboard); optional (`frontend.deploy`, default `true`). -Two more subcharts are bundled for local development only, off by default: `keycloak` (dev mode, embedded database, known admin login) and `fakeidp` (a stateless stub). Neither is a stand's IdP — this runbook expects the real one from [Prerequisites](#cluster-level-dependencies). +One more subchart is bundled, off by default: `keycloak` (the stack's own Keycloak, MariaDB-backed, with realm content managed as code by this repo's environments). It is not a stand's IdP in this runbook — this runbook expects the real one from [Prerequisites](#cluster-level-dependencies). You supply one values file, secret files, and optionally one Secret per connector (see [deploy/CONNECTORS.md](./CONNECTORS.md)) — no GitOps repo, CI or auto-reconciliation. The data infrastructure is your side of the contract; see [Prerequisites](#running-external-infrastructure). @@ -62,7 +62,7 @@ You supply one values file, secret files, and optionally one Secret per connecto Install all three of these before the chart — it bundles none of them: - **An ingress controller.** Install ingress-nginx, or point `gateway.ingress.className` at what you run (default `nginx`). The gateway owns the only Ingress: UI at `/`, APIs under `/api/`. -- **A real OIDC identity provider** — Entra ID, Okta, Auth0, or your own. OIDC is mandatory and there is no auth-off switch. **No IdP on the stand? Install Keycloak as its own release**, on a hostname the browser and the authenticator pod resolve identically, then read its issuer, client ID and client secret in Step 0. The bundled `keycloak`/`fakeidp` subcharts are dev-mode servers for local development, not this. +- **A real OIDC identity provider** — Entra ID, Okta, Auth0, or your own. OIDC is mandatory and there is no auth-off switch. **No IdP on the stand? Install Keycloak as its own release**, on a hostname the browser and the authenticator pod resolve identically, then read its issuer, client ID and client secret in Step 0. The bundled `keycloak` subchart is wired for this repo's own environments (roster realm, config-cli-managed content), not this. - **cert-manager, plus a `ClusterIssuer` of your own.** The authenticator's TLS-discovery sidecar (`authenticator.tlsDiscovery.enabled`, default `true`) creates a `cert-manager.io/v1` `Certificate`, and Analytics verifies the authenticator's JWKS against that CA — load-bearing, not optional. Point `authenticator.tlsDiscovery.issuerRef.name` at an issuer your cluster actually has: the chart's `local-ca` default exists only in this repo's local k3s sandbox (`deploy/gitops/bootstrap/local/selfsigned-issuer.yaml`). Any issuer works, self-signed included — the certificate is internal-only and unrelated to the ingress certificate in ``. (Identity Resolution verifies the same way as Analytics.) Confirm the cluster-side pieces (the IdP gets verified in Step 0, once you have its issuer URL): @@ -110,7 +110,7 @@ Generate the tenant ID, then read the rest off the cluster. Each dependency may ### Generate the tenant ID -A lowercase UUID, used verbatim for both `global.tenantDefaultId` and `ingestion.reconcile.tenantId`, and never changed after the first sync. (Local/dev against the compose wizard, the seed generators or `fakeidp` reuses their fixed `00000000-df51-5b42-9538-d2b56b7ee953`.) `global.tenantDefaultId` is only a fallback — the request tenant comes from the id_token claim named by `authenticator.oidc.tenantClaim`, default `tenant_id` — so if your IdP asserts that claim, give it this same UUID. +A lowercase UUID, used verbatim for both `global.tenantDefaultId` and `ingestion.reconcile.tenantId`, and never changed after the first sync. (Local/dev against the compose wizard or the seed generators reuses their fixed `00000000-df51-5b42-9538-d2b56b7ee953`.) `global.tenantDefaultId` is only a fallback — the request tenant comes from the id_token claim named by `authenticator.oidc.tenantClaim`, default `tenant_id` — so if your IdP asserts that claim, give it this same UUID. ```sh uuidgen | tr '[:upper:]' '[:lower:]' # no uuidgen? python3 -c 'import uuid; print(uuid.uuid4())' @@ -299,7 +299,7 @@ Check these before installing: - `identityResolution.deploy: true` is the chart default — leave it alone unless you have a specific reason to disable it. This is what the authenticator's login-bootstrap resolve actually depends on; `insight.validate` refuses to render without it. - Set real values for `authenticator.oidc.issuerUrl`, `redirectUri`, and `sourceType`. The chart wraps all three in Helm's `required`, and there is no auth-off switch. - Create the Secret named in `authenticator.signingKeysSecret` before installing (Step 2). The chart does not generate it. -- Point the OIDC fields at the real IdP from Prerequisites. The bundled `keycloak`/`fakeidp` subcharts are dev-mode servers for local development, not a stand's IdP. +- Point the OIDC fields at the real IdP from Prerequisites. The bundled `keycloak` subchart is wired for this repo's own environments, not a stand's IdP in this runbook. ## Step 2 — Fill the secret files @@ -377,7 +377,7 @@ Run all four checks: ```sh kubectl -n insight get pods # expect insight-gateway, -authenticator, -analytics, -identity-resolution, -frontend all Running - # (fakeidp/keycloak only appear with their own deploy flag) + # (keycloak only appears with its own deploy flag) kubectl -n insight get secret insight-analytics-config insight-authenticator-config insight-identity-resolution-config # the chart composes these from insight-db-creds @@ -441,7 +441,7 @@ Other notable (non-placeholder) settings in this file: - `credentials.deploymentMode: helm` and `credentials.autoGenerate: true` — this enables the "bring your own" credentials path, where the chart keeps a labelless `insight-db-creds` Secret instead of generating random passwords. - `identityResolution.deploy: true` — the chart default; don't flip it off. - `authenticator.tlsDiscovery.issuerRef.name` — the cert-manager `ClusterIssuer` the JWKS-discovery Certificate is issued from. Always set this: the chart ships `local-ca`, which is the self-signed root that `make bootstrap-cert-manager ENV=local` creates for the local k3s sandbox, not anything a real cluster has. -- There is no auth-off toggle anywhere in this chart. `authenticator.oidc.issuerUrl` and `authenticator.oidc.redirectUri` are hard `required` fields, so a real IdP is a prerequisite; install Keycloak as a separate release if the stand has none. The bundled `keycloak`/`fakeidp` subcharts are local-development servers (embedded database, known passwords) and not a substitute. +- There is no auth-off toggle anywhere in this chart. `authenticator.oidc.issuerUrl` and `authenticator.oidc.redirectUri` are hard `required` fields, so a real IdP is a prerequisite; install Keycloak as a separate release if the stand has none. The bundled `keycloak` subchart is wired for this repo's own environments (roster realm, config-cli-managed content) and not a substitute here. ### secrets/insight-db-creds.yaml keys diff --git a/deploy/compose/authenticator-fullauth.yaml b/deploy/compose/authenticator-fullauth.yaml index ce4fdd900..214faf153 100644 --- a/deploy/compose/authenticator-fullauth.yaml +++ b/deploy/compose/authenticator-fullauth.yaml @@ -118,7 +118,7 @@ gears: client_id: "" client_secret: "" # id_token claim naming the user's single tenant (string; an array is - # tolerated — first entry wins). fakeidp/Keycloak emit `tenant_id`; + # tolerated — first entry wins). Keycloak emits `tenant_id`; # Entra emits `tid`. tenant_claim: "tenant_id" # Fallback tenant for a claim-less IdP (e.g. Okta); empty = fail closed. diff --git a/deploy/compose/insight-init.sh b/deploy/compose/insight-init.sh index 48f806e73..0e38d8bdc 100755 --- a/deploy/compose/insight-init.sh +++ b/deploy/compose/insight-init.sh @@ -752,9 +752,9 @@ EOF # holds the committed sandbox config. cp "$values_tmpl" "$values_out" yq -i ".global.tenantDefaultId = \"$TENANT_DEFAULT_ID\"" "$values_out" - # Full auth: the dev login identity is the fakeidp default user (must exist in + # Full auth: the dev login identity is a realm user (must exist in # identity's `persons`), not a frontend impersonation escape hatch. - yq -i ".fakeidp.devUserEmail = \"$DEV_USER_EMAIL\"" "$values_out" + yq -i ".keycloak.devUserEmail = \"$DEV_USER_EMAIL\"" "$values_out" echo "Wrote $values_out." >&2 cat >&2 </dev/null 2>&1 || { echo "$(C_RED)uv is required to generate the Keycloak realm$(C_RST)"; exit 1; }; \ DIR=environments/$(ENV)/keycloak/realms; mkdir -p "$$DIR"; \ OUT="$$DIR/realm-insight.generated.json"; \ - DEV_EMAIL="$$(yq -r '.fakeidp.devUserEmail // "dev@company.nonpresent"' $(VALUES))"; \ + DEV_EMAIL="$$(yq -r '.keycloak.devUserEmail // "dev@company.nonpresent"' $(VALUES))"; \ REDIRECT="$$(yq -r '.authenticator.oidc.redirectUri // "http://localhost/auth/callback"' $(VALUES))"; \ AUTH_SECRET="$$(yq -r '.authenticator.oidc.clientSecret // "insight-authenticator-dev-secret"' $(VALUES))"; \ KC_BASE="$$(yq -r '.keycloak.hostname // ""' $(VALUES))"; \ diff --git a/deploy/gitops/environments/functional-ci/values.yaml b/deploy/gitops/environments/functional-ci/values.yaml index e822ec7dc..08aa39523 100644 --- a/deploy/gitops/environments/functional-ci/values.yaml +++ b/deploy/gitops/environments/functional-ci/values.yaml @@ -43,32 +43,59 @@ authenticator: issuerRef: name: local-ca kind: ClusterIssuer + # IdP = the in-stack Keycloak (below). No browser participates in this + # smoke, so the issuer is the cluster-internal Service URL — the + # authenticator's per-login discovery is the only consumer. clientSecret + # is the dev secret the realm generator bakes into the generated realm + # (keycloak-realm make target); not sensitive. oidc: - issuerUrl: 'http://{{ .Release.Name }}-fakeidp.{{ .Release.Namespace }}.svc.cluster.local:8084' + issuerUrl: 'http://{{ .Release.Name }}-keycloak.{{ .Release.Namespace }}.svc.cluster.local:8085/kc/realms/insight' clientId: "insight-authenticator" + clientSecret: "insight-authenticator-dev-secret" redirectUri: "http://localhost/auth/callback" - # fakeidp's `sub` claim IS the stable per-user id (see users.yaml) — no - # real connector backs it, but the persons-seed fixtures used in this - # environment key their `value_type='id'` rows on this made-up source_type. - sourceType: "fakeidp" + # The realm generator pins each realm user's `sub` to their roster uuid, + # and persons-seed fixtures key their `value_type='id'` rows on this + # source_type. + sourceType: "keycloak" + externalIdClaim: "sub" -fakeidp: +# keycloak — the in-stack Keycloak (roster realm generated by the +# keycloak-realm make target, applied via keycloak-config-cli like every env). +# `hostname` is the advertised issuer base; no ingress in this cluster, so it +# is the concrete Service FQDN (release/namespace pinned in inventory.yaml — +# the keycloak-realm recipe reads this value raw, so no tpl string here). +keycloak: deploy: true - issuer: 'http://{{ .Release.Name }}-fakeidp.{{ .Release.Namespace }}.svc.cluster.local:8084' - devUserEmail: "dev@company.nonpresent" + hostname: 'http://insight-keycloak.insight.svc.cluster.local:8085/kc' + admin: + existingSecret: insight-keycloak-config # CI admin/admin (keycloak-realm target) + database: + host: mariadb.insight-infra.svc.cluster.local + username: insight + passwordSecret: {name: insight-db-creds, key: mariadb-password} + +keycloakConfig: + enabled: true + # The roster realm is generated JSON (keycloak-realm target). + filesLocations: "/config/*.yaml,/config/*.json" + # In-cluster hop over plain http — ephemeral CI cluster only. + url: 'http://{{ .Release.Name }}-keycloak:8085/kc' + allowInsecureUrl: true + extraEnv: + - name: KEYCLOAK_USER + valueFrom: + secretKeyRef: {name: insight-keycloak-config, key: username} + - name: KEYCLOAK_PASSWORD + valueFrom: + secretKeyRef: {name: insight-keycloak-config, key: password} analytics: replicaCount: 1 -identityResolution: - deploy: true - # The authenticator's login-bootstrap resolve (GET /internal/persons/ -# by-external-id / by-email-override) is Rust-only — the frozen .NET -# `identity` twin above never gained it (constructorfabric/insight#1960) — so -# identity-resolution must ALSO be deployed here, and the authenticator talks -# to it unconditionally (deploy/gitops/scripts/compose-app-secrets.sh enforces -# this at apply time regardless of what `.identityUrl` — analytics' own -# .NET<->Rust switch — is set to). +# by-external-id / by-email-override) lives on identity-resolution +# (constructorfabric/insight#1960), so it must be deployed here — the +# authenticator talks to it unconditionally +# (deploy/gitops/scripts/compose-app-secrets.sh enforces this at apply time). identityResolution: deploy: true diff --git a/deploy/gitops/environments/local/inventory.yaml.template b/deploy/gitops/environments/local/inventory.yaml.template index 8e5ff5d94..bc18c7b0f 100644 --- a/deploy/gitops/environments/local/inventory.yaml.template +++ b/deploy/gitops/environments/local/inventory.yaml.template @@ -61,7 +61,7 @@ secrets: # ES256 gateway-JWT signing key (current.pem) mounted by the authenticator. - { name: insight-authenticator-signing-keys, enabled: true } # Real-IdP OIDC secret. Off for local — the authenticator logs in against the - # in-cluster fakeidp; its leaf config is composed at deploy time by + # in-cluster Keycloak; its leaf config is composed at deploy time by # compose-app-secrets.sh, not sealed here. - { name: insight-oidc, enabled: false } # config-cli login + realm placeholder values (ADR-0003 broker realms). diff --git a/deploy/gitops/environments/local/values.yaml.template b/deploy/gitops/environments/local/values.yaml.template index 6d5119208..7831eeb4e 100644 --- a/deploy/gitops/environments/local/values.yaml.template +++ b/deploy/gitops/environments/local/values.yaml.template @@ -85,8 +85,9 @@ ingestion: # Full auth (NGINX_BFF EPIC #1583) — no auth_disabled anywhere. The nginx # `gateway` is the single edge: auth_request -> authenticator, ES256 gateway-JWT -# injection, fan-out to the app services + SPA. `fakeidp` is the local OIDC -# provider the authenticator logs in against; every downstream verifies the JWT. +# injection, fan-out to the app services + SPA. The in-stack Keycloak is the +# local OIDC provider the authenticator logs in against; every downstream +# verifies the JWT. gateway: image: { repository: insight-gateway, tag: dev, pullPolicy: IfNotPresent } replicaCount: 1 @@ -133,7 +134,7 @@ authenticator: # already IS that stable id — no Entra-style non-default claim needed here. externalIdClaim: "sub" # Optional tenant sourcing: `tenantClaim` names the id_token claim carrying - # the single tenant (default `tenant_id` — fakeidp/Keycloak; Entra: `tid`); + # the single tenant (default `tenant_id` — Keycloak; Entra: `tid`); # `defaultTenantId` is the fallback for a claim-less IdP (e.g. Okta), # empty = fail closed. @@ -153,6 +154,8 @@ authenticator: keycloak: deploy: true hostname: 'http://__INGRESS_LB_IP__/kc' + # Dev-lead login for the generated roster realm (keycloak-realm target). + devUserEmail: "dev@company.nonpresent" admin: existingSecret: insight-keycloak-config # sandbox admin/admin (keycloak-realm target) database: @@ -178,17 +181,6 @@ keycloakConfig: valueFrom: secretKeyRef: {name: insight-keycloak-config, key: password} -# fakeidp — the lighter alt IdP; off for local (keycloak is the local IdP). Flip -# fakeidp.deploy=true + keycloak.deploy=false (and point authenticator.oidc.issuerUrl -# back at http://__INGRESS_LB_IP__/idp) to use it instead. -fakeidp: - image: { repository: insight-fakeidp, tag: dev, pullPolicy: IfNotPresent } - deploy: false - ingress: - enabled: false - issuer: 'http://__INGRESS_LB_IP__/idp' - devUserEmail: "dev@company.nonpresent" - analytics: image: { repository: insight-analytics, tag: dev, pullPolicy: IfNotPresent } replicaCount: 1 diff --git a/deploy/gitops/scripts/compose-app-secrets.sh b/deploy/gitops/scripts/compose-app-secrets.sh index a1fdf393b..031001d42 100755 --- a/deploy/gitops/scripts/compose-app-secrets.sh +++ b/deploy/gitops/scripts/compose-app-secrets.sh @@ -137,7 +137,7 @@ AUTH_REDIRECT_URI=$(render_tpl "$(yq -r '.authenticator.oidc.redirectUri // ""' # only issues a refresh token WITH offline_access (e.g. Entra) adds it here. AUTH_SCOPES=$(yq -r '(.authenticator.oidc.scopes // ["openid","email","profile"]) | join(" ")' "$VALUES") # Tenant sourcing: the id_token claim naming the single tenant (`tenant_id` on -# fakeidp/Keycloak, `tid` on Entra) and the fallback for a claim-less IdP +# Keycloak, `tid` on Entra) and the fallback for a claim-less IdP # (e.g. Okta). Empty fallback = fail closed downstream. AUTH_TENANT_CLAIM=$( yq -r '.authenticator.oidc.tenantClaim // "tenant_id"' "$VALUES") AUTH_DEFAULT_TENANT_ID=$(yq -r '.authenticator.oidc.defaultTenantId // ""' "$VALUES") diff --git a/deploy/gitops/secrets-store.yaml.template b/deploy/gitops/secrets-store.yaml.template index 3f62ed514..65e9a7267 100644 --- a/deploy/gitops/secrets-store.yaml.template +++ b/deploy/gitops/secrets-store.yaml.template @@ -85,10 +85,10 @@ insight-local-insight-authenticator-signing-keys: | REPLACE_WITH_A_GENERATED_P256_PKCS8_KEY -----END PRIVATE KEY----- -## insight-oidc — required only when an external IdP is configured (real envs -## set fakeidp.deploy: false). Skip for the local sandbox: the authenticator -## logs in against the in-cluster fakeidp and its leaf config is composed at -## deploy time by compose-app-secrets.sh (not sealed here). +## insight-oidc — required only when an external IdP is configured. Skip for +## the local sandbox: the authenticator logs in against the in-cluster Keycloak +## and its leaf config is composed at deploy time by compose-app-secrets.sh +## (not sealed here). ## ## Seven keys; see the umbrella chart's api-gateway secret.yaml for the ## exact env-var-named schema (APP__gears__oidc-authn-plugin__config__* diff --git a/dev-compose.sh b/dev-compose.sh index 7e99ef0b1..f4c001256 100755 --- a/dev-compose.sh +++ b/dev-compose.sh @@ -313,7 +313,7 @@ ghcr_volumes_block() { # binary, as `source:target[:mode]` relative to the repo root. ghcr_kept_mounts() { local svc="$1" out - out="$(docker compose -f docker-compose.yml --profile auth-keycloak --profile auth-fakeidp \ + out="$(docker compose -f docker-compose.yml --profile auth-keycloak \ config --format json 2>/dev/null | SERVICE="$svc" python3 -c ' import json, os, sys @@ -399,7 +399,7 @@ cmd_up() { --frontend-mode=*) frontend_mode_override="${1#*=}"; shift ;; --frontend-mode) frontend_mode_override="$2"; shift 2 ;; --auth=*|--auth) - echo "ERROR: --auth was removed — auth always runs via Keycloak (fakeidp is retired)." >&2 + echo "ERROR: --auth was removed — auth always runs via Keycloak." >&2 return 2 ;; --authenticator-redirect=*) authenticator_redirects="$(add "$authenticator_redirects" "${1#*=}")"; shift ;; @@ -457,8 +457,8 @@ cmd_up() { [[ -n "$frontend_mode_override" ]] && FRONTEND_MODE="$frontend_mode_override" FRONTEND_MODE="${FRONTEND_MODE:-dev}" - # Auth always runs via Keycloak; fakeidp is retired. A lingering - # AUTH_MODE=fakeidp in an old .env.compose is overridden, loudly. + # Auth always runs via Keycloak. A lingering non-keycloak AUTH_MODE in an + # old .env.compose is overridden, loudly. if [[ "${AUTH_MODE:-keycloak}" != "keycloak" ]]; then echo "WARN: AUTH_MODE=${AUTH_MODE} is retired — auth always runs via Keycloak." >&2 echo " Remove AUTH_MODE from $env_file to silence this." >&2 @@ -658,11 +658,9 @@ YML export AUTHENTICATOR_OIDC_ISSUER="${AUTHENTICATOR_OIDC_ISSUER:-${kc_base}/realms/insight}" export OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-insight-authenticator}" export OIDC_CLIENT_SECRET="${OIDC_CLIENT_SECRET:-insight-authenticator-dev-secret}" - # The login-bootstrap resolve is scoped to idp.source_type; keycloak's - # sub differs in KIND from fakeidp's (keycloak_realm sets each realm user's - # id to their OWN roster uuid, so sub IS that uuid — not the fixed - # "fakeidp|dev" string fakeidp issues), so it must be seeded/looked-up - # under its own source_type, not the fakeidp default (see + # The login-bootstrap resolve is scoped to idp.source_type: keycloak_realm + # sets each realm user's id to their OWN roster uuid, so sub IS that uuid and + # must be seeded/looked-up under the `keycloak` source_type (see # src/ingestion/tools/seed/profiles.py::get_login_id_pairs). export AUTHENTICATOR_IDP_SOURCE_TYPE="keycloak" echo "authenticator issuer → ${AUTHENTICATOR_OIDC_ISSUER}" @@ -764,11 +762,10 @@ YML contains "$ghcr_list" "$svc" && mkdir -p "deploy/compose/build/$svc" done - # Stop a fakeidp lingering from a stack started before its retirement. - # Compose profiles decide what to START, not what to stop, so without this - # an in-place `up` would leave both IdPs running. The auth-fakeidp profile - # puts the target service in scope for `stop`. - "${compose_cmd[@]}" --profile auth-fakeidp --profile auth-keycloak stop fakeidp >/dev/null 2>&1 || true + # Remove a fakeidp container lingering from a stack started before its + # retirement: the service no longer exists in docker-compose.yml, so an + # in-place `up` would otherwise leave both IdPs running. + docker rm -f "${COMPOSE_PROJECT_NAME:-insight}-fakeidp" >/dev/null 2>&1 || true echo "=== docker compose up ===" if ! "${compose_cmd[@]}" ${profiles[@]+"${profiles[@]}"} up -d --remove-orphans; then @@ -961,7 +958,7 @@ cmd_down() { "${compose_cmd[@]}" \ --profile local-mariadb --profile local-clickhouse \ --profile front-dev --profile front-built --profile front-ghcr \ - --profile auth-fakeidp --profile auth-keycloak \ + --profile auth-keycloak \ --profile build --profile seed \ --profile local-mariadb --profile local-clickhouse \ down $([[ "$wipe" == "true" ]] && echo "--volumes --remove-orphans") @@ -1243,7 +1240,7 @@ EOF echo "=== docker compose down --volumes --remove-orphans ===" "${compose_cmd[@]}" \ --profile front-dev --profile front-built --profile front-ghcr \ - --profile auth-fakeidp --profile auth-keycloak \ + --profile auth-keycloak \ --profile build --profile seed \ --profile local-mariadb --profile local-clickhouse \ down --volumes --remove-orphans || true diff --git a/docker-compose.yml b/docker-compose.yml index 2b757f020..724c4ad1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,9 +188,7 @@ services: # (The legacy Rust api-gateway is gone — the nginx `gateway` below is the sole # :8080 entry, doing full auth via the authenticator, NGINX_BFF #1583 step 09.) # Auth runs via Keycloak (real login form + generated realm): full cookie/BFF - # auth, the authenticator does the OIDC flow. fakeidp is retired — its - # service lingers below only until its removal lands; dev-compose.sh never - # starts it. + # auth, the authenticator does the OIDC flow. analytics: !!merge <<: *backend-common @@ -346,14 +344,14 @@ services: # ── Authenticator (OIDC login + sessions + cookie-to-JWT exchange) ───── # # The BFF / token-handler service (nginx+auth step 04). Runs the OIDC - # code+PKCE login against fakeidp, keeps opaque sessions in Redis, and mints + # code+PKCE login against Keycloak, keeps opaque sessions in Redis, and mints # the linked ES256 gateway JWT served at /internal/authz. signing_keys_path # points at a bind-mounted dev key that dev-compose.sh generates on `up` # (deploy/compose/authenticator-dev-keys, gitignored — never baked into an # image); prod mounts a K8s Secret. Login requires the person to exist in # Identity (seed the DB with - # ./dev-compose.sh seed identity — the fakeidp dev user resolves to a seeded - # person); an unknown person is denied (403). First-admin bootstrap / RBAC are + # ./dev-compose.sh seed identity — realm users resolve to seeded + # persons); an unknown person is denied (403). First-admin bootstrap / RBAC are # out of scope (a separate universe-admin initiative). authenticator: !!merge <<: *backend-common @@ -364,9 +362,6 @@ services: dockerfile: services/authenticator/Dockerfile depends_on: redis: {condition: service_healthy} - # required: false → compose skips this dependency when the fakeidp - # profile is inactive (it always is — dev-compose.sh runs auth-keycloak). - fakeidp: {condition: service_started, required: false} identity-resolution: {condition: service_started} environment: !!merge <<: *backend-env @@ -377,9 +372,10 @@ services: # the JWKS over https (the plugin's runtime is https-only). The browser # still reaches the gateway plainly at :8080. APP__gears__authenticator__config__gateway_issuer: "${AUTHENTICATOR_GATEWAY_ISSUER:-https://authn-tls:8443}" - # OIDC: discovery/token/JWKS are fetched in-network at fakeidp:8084; the - # browser-facing redirect_uri is host-visible (the SPA / e2e runs there). - APP__gears__authenticator__config__idp__issuer_url: "${AUTHENTICATOR_OIDC_ISSUER:-http://fakeidp:8084}" + # OIDC: the issuer must equal Keycloak's advertised KC_HOSTNAME issuer + # (dev-compose.sh exports both from the host IP); the browser-facing + # redirect_uri is host-visible (the SPA / e2e runs there). + APP__gears__authenticator__config__idp__issuer_url: "${AUTHENTICATOR_OIDC_ISSUER:-http://localhost:8085/kc/realms/insight}" APP__gears__authenticator__config__idp__client_id: "${OIDC_CLIENT_ID:-insight-authenticator}" APP__gears__authenticator__config__idp__client_secret: "${OIDC_CLIENT_SECRET:-}" # Required: the identity-resolution source_type the login-bootstrap @@ -425,52 +421,12 @@ services: - "${AUTHENTICATOR_PORT:-8083}:8083" - "${AUTHENTICATOR_TOKEN_PORT:-8093}:8093" - # ── Fake IdP (dev/e2e ONLY — never shipped to production) ────────────── - # - # A tiny in-repo OIDC provider so the authenticator's real login / refresh / - # logout code path runs locally and in CI with no external IdP. It signs with - # a throwaway keypair generated at startup and exposes /_control/* test hooks. Builds - # entirely from its own Dockerfile (no host-built-binary bind mount), so it is - # self-contained. See src/backend/services/fakeidp/README.md and - # cf/NGINX_BFF.md §10 G6. Gated behind --profile auth-fakeidp (the default - # auth mode); --profile auth-keycloak brings up `keycloak` instead. - fakeidp: - !!merge <<: *backend-common - profiles: ["auth-fakeidp"] - image: ${FAKEIDP_IMAGE:-insight-fakeidp:dev} - container_name: ${COMPOSE_PROJECT_NAME:-insight}-fakeidp - build: - context: src/backend - dockerfile: services/fakeidp/Dockerfile - environment: - # Issuer is the in-network URL: the authenticator (step 04) fetches - # discovery / JWKS / token here and validates the id_token `iss` against - # it, so it must match the URL used inside the compose network. The - # host-curl README flow rewrites fakeidp:8084 -> localhost:8084. - FAKEIDP_ISSUER: "${FAKEIDP_ISSUER:-http://fakeidp:8084}" - FAKEIDP_BIND: "0.0.0.0:8084" - # Audience of issued tokens = the authenticator's OIDC client_id. - FAKEIDP_DEFAULT_AUD: "${OIDC_CLIENT_ID:-insight-authenticator}" - FAKEIDP_TOKEN_TTL: "${FAKEIDP_TOKEN_TTL:-300}" - # Default login identity: the dev-impersonation person the seeder writes - # into identity (dev-compose.sh's DEV_USER_EMAIL). Overrides the - # first user in users.yaml so a plain login resolves to a seeded person. - FAKEIDP_DEV_USER_EMAIL: "${DEV_USER_EMAIL:-dev@company.nonpresent}" - # Back-channel logout receiver on the authenticator (endpoint lands in a - # later step; wired here so the /_control/backchannel hook has a target). - FAKEIDP_BACKCHANNEL_URL: "${FAKEIDP_BACKCHANNEL_URL:-http://authenticator:8083/auth/oidc/back-channel-logout}" - RUST_LOG: ${RUST_LOG:-info} - ports: - - "${FAKEIDP_PORT:-8084}:8084" - - # ── Keycloak (dev/e2e realm-import IdP — the real-OIDC counterpart to - # fakeidp) ─────────────────────────────────────────────────────────────── + # ── Keycloak (dev/e2e realm-import IdP) ──────────────────────────────── # # Single container, embedded H2, imports the roster-generated realm at # deploy/compose/keycloak/realm-insight.generated.json (produced by # insight_seed.keycloak_realm, gitignored). Gated behind - # --profile auth-keycloak; mutually exclusive with fakeidp's auth-fakeidp - # profile. + # --profile auth-keycloak. keycloak: image: quay.io/keycloak/keycloak:26.4 container_name: ${COMPOSE_PROJECT_NAME:-insight}-keycloak @@ -727,10 +683,9 @@ services: # above) — the dev-lead's login-bootstrap value_type='id' row is seeded # under this source_type. IDP_SOURCE_TYPE: "${AUTHENTICATOR_IDP_SOURCE_TYPE:-keycloak}" - # Selects which roster personas get a login-id fixture (fakeidp: dev lead - # only, its fixed "fakeidp|dev"; keycloak: the whole roster, each on their - # own uuid — see src/ingestion/tools/seed/profiles.py's get_login_id_pairs). - # dev-compose.sh exports this; keycloak is the only mode it runs. + # Keycloak mode seeds a login-id fixture for the whole roster, each + # persona on their own uuid — see src/ingestion/tools/seed/profiles.py's + # get_login_id_pairs. dev-compose.sh exports this. AUTH_MODE: "${AUTH_MODE:-keycloak}" # MariaDB — falls back to the local docker service name/port; the # wizard rewrites these when external MariaDB is selected. diff --git a/docs/components/backend/authenticator/DESIGN.md b/docs/components/backend/authenticator/DESIGN.md index 918565601..ab4ea9115 100644 --- a/docs/components/backend/authenticator/DESIGN.md +++ b/docs/components/backend/authenticator/DESIGN.md @@ -111,7 +111,9 @@ The authenticator is a plain HTTP service: no proxying, no K8s API access, no st -- Keycloak adopted as the identity broker for customer IdPs and social logins (GitHub #2163), presenting one uniform OIDC issuer to the authenticator; realm content managed as code (keycloak-config-cli from gitops, secrets via sealed secrets); per-provider mappers inject the - single `tenant_id` claim (DD-AUTH-04); amends ADR-0002 by retiring fakeidp everywhere. + single `tenant_id` claim (DD-AUTH-04); amends ADR-0002 by retiring fakeidp everywhere + (retirement completed 2026-08-07, #2198 -- Keycloak is the issuer in every dev/CI/e2e + environment; deployed environments use the broker). All three realise `cpt-insightspec-fr-auth-oidc-login` wiring without a code change -- the IdP stays a config value. Remaining decisions are captured inline in diff --git a/docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md b/docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md index 6eb60315c..c754ef88c 100644 --- a/docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md +++ b/docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md @@ -11,6 +11,9 @@ superseded_by: cpt-insightspec-adr-auth-0002-real-idp-on-deployed-stands **Status history**: - 2026-07-30: ACCEPTED -> SUPERSEDED (real IdP required on deployed stands; see ADR-0002) +- 2026-08-07: NOTE -- fakeidp itself is retired and deleted from the repository per ADR-0003 + (Keycloak identity broker; issue #2198); every dev/test environment now logs in against the + in-stack Keycloak with the seed-generated roster realm. diff --git a/docs/components/backend/authenticator/specs/ADR/0002-real-idp-on-deployed-stands.md b/docs/components/backend/authenticator/specs/ADR/0002-real-idp-on-deployed-stands.md index cd864d783..8cf13c166 100644 --- a/docs/components/backend/authenticator/specs/ADR/0002-real-idp-on-deployed-stands.md +++ b/docs/components/backend/authenticator/specs/ADR/0002-real-idp-on-deployed-stands.md @@ -22,6 +22,10 @@ date: 2026-07-30 remains a non-channel. Option B's per-stand footprint objection is answered by reusing the stack's MariaDB and by the broker being production auth infrastructure, not test scaffolding. The one-realm-per-environment and realm-generation decisions stand. +- 2026-08-07: NOTE -- the fakeidp retirement that the 2026-08-04 amendment recorded is now + executed (issue #2198): the fakeidp crate, subchart and compose service are deleted; the + functional-CI environment and the gateway e2e rigs run the in-stack Keycloak with the + seed-generated roster realm. diff --git a/docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md b/docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md index f9bfa92a6..6664e1751 100644 --- a/docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md +++ b/docs/components/backend/authenticator/specs/ADR/0003-keycloak-identity-broker.md @@ -9,6 +9,10 @@ date: 2026-08-04 **Status history**: +- 2026-08-07: NOTE -- fakeidp retirement COMPLETED (issue #2198): the fakeidp crate, subchart + and compose service are deleted; the functional-CI environment and the gateway e2e rigs run + the in-stack Keycloak with the seed-generated roster realm (imported via keycloak-config-cli / + `--import-realm`). Compose and the authenticator e2e were already Keycloak-only. - 2026-08-06: AMENDED -- claim-value-to-tenant translation (the advanced claim-to-group mapper sketched in the Decision Outcome) is REJECTED: the tenant is always the fixed per-registration pin from environment values, an IdP's own tenancy assertions are never consulted, and a @@ -283,7 +287,8 @@ findings notes and the reproducible realm YAML live on the Phase 0 issue, #2194) - Migration order: provision broker realms as code first; move social providers and new customer IdPs behind the broker immediately; re-point each environment's `issuerUrl` from its directly wired IdP to the broker realm as it is onboarded -- per environment, no flag day; retire - fakeidp last, once compose and CI default to the Keycloak realm. + fakeidp last, once compose and CI default to the Keycloak realm. **Done 2026-08-07 (#2198)**: + fakeidp is deleted; compose, CI and the e2e rigs all run the Keycloak roster realm. ## Traceability diff --git a/docs/components/deployment/specs/DESIGN.md b/docs/components/deployment/specs/DESIGN.md index 931a1d578..0bf0121c0 100644 --- a/docs/components/deployment/specs/DESIGN.md +++ b/docs/components/deployment/specs/DESIGN.md @@ -5,25 +5,27 @@ date: 2026-05-12 # Technical Design — Deployment -## Table of Contents - -1. [1. Architecture Overview](#1-architecture-overview) - - [1.1 Architectural Vision](#11-architectural-vision) - - [1.2 Architecture Drivers](#12-architecture-drivers) - - [1.3 Architecture Layers](#13-architecture-layers) -2. [2. Principles & Constraints](#2-principles--constraints) - - [2.1 Design Principles](#21-design-principles) - - [2.2 Constraints](#22-constraints) -3. [3. Technical Architecture](#3-technical-architecture) - - [3.1 Domain Model](#31-domain-model) - - [3.2 Component Model](#32-component-model) - - [3.3 API Contracts](#33-api-contracts) - - [3.4 Internal Dependencies](#34-internal-dependencies) - - [3.5 External Dependencies](#35-external-dependencies) - - [3.6 Interactions & Sequences](#36-interactions--sequences) - - [3.7 Database schemas & tables](#37-database-schemas--tables) -4. [4. Additional context](#4-additional-context) -5. [5. Traceability](#5-traceability) + + +- [1. Architecture Overview](#1-architecture-overview) + - [1.1 Architectural Vision](#11-architectural-vision) + - [1.2 Architecture Drivers](#12-architecture-drivers) + - [1.3 Architecture Layers](#13-architecture-layers) +- [2. Principles & Constraints](#2-principles--constraints) + - [2.1 Design Principles](#21-design-principles) + - [2.2 Constraints](#22-constraints) +- [3. Technical Architecture](#3-technical-architecture) + - [3.1 Domain Model](#31-domain-model) + - [3.2 Component Model](#32-component-model) + - [3.3 API Contracts](#33-api-contracts) + - [3.4 Internal Dependencies](#34-internal-dependencies) + - [3.5 External Dependencies](#35-external-dependencies) + - [3.6 Interactions & Sequences](#36-interactions--sequences) + - [3.7 Database schemas & tables](#37-database-schemas--tables) +- [4. Additional context](#4-additional-context) +- [5. Traceability](#5-traceability) + + ## 1. Architecture Overview @@ -522,14 +524,13 @@ Per-tag artifacts are immutable; the Chart Publishing CI does not overwrite. GHC |----------|-------------|-----------| | `ENABLE_AUTO_RELOAD` | Wraps each backend entrypoint in `watchexec --restart` for ~1s reload. Compose-only — never set in a Kubernetes manifest. | stable | | `FRONTEND_MODE` | `ghcr` (published image, default), `dev` (Vite HMR from `src/frontend`), or `built` (host-built dist). | stable | -| `AUTH_MODE` | `fakeidp` (default, in-repo test IdP) or `keycloak` (real login via a bundled Keycloak container — see [`deploy/compose/keycloak/README.md`](../../../../deploy/compose/keycloak/README.md)). Persisted here; a per-run `--auth` flag overrides it. | stable | | `_IMAGE` | Pull a published image for a backend service instead of building it (e.g. `API_GATEWAY_IMAGE`). | stable | | `*_PORT` | Host port for each published service (Frontend :3000, gateway :8080, …); override on conflict. | stable | | `MARIADB_EXTERNAL` / `_HOST` / `_INTERNAL_PORT`, ClickHouse equivalents | Point the stack at an external DB instead of the bundled container. | stable | | `TENANT_DEFAULT_ID` | Tenant UUID used by the seed and the dev caller context. | stable | | `SEEDED_LOCAL_MARIA` / `SEEDED_LOCAL_CH` | First-run seed bookkeeping; clear to force a re-seed on next `up`. | stable | -`.env.compose.example` documents the full settings contract. The stack is local-only; none of these settings reach the canonical chart values or any published artifact. +`.env.compose.example` documents the full settings contract. Auth always runs via the bundled Keycloak container (realm generated from the seed roster — see [`deploy/compose/keycloak/README.md`](../../../../deploy/compose/keycloak/README.md)). The stack is local-only; none of these settings reach the canonical chart values or any published artifact. ### 3.4 Internal Dependencies diff --git a/scripts/ci/components.py b/scripts/ci/components.py index 50e1f99cc..ae283c03f 100755 --- a/scripts/ci/components.py +++ b/scripts/ci/components.py @@ -80,17 +80,6 @@ # own component); `triggered_by` is the registry's co-trigger for this. "triggered_by": ["insight-clickhouse"], }, - # fakeidp is a dev/e2e test double (see cf/NGINX_BFF.md §10 G6), not shipped - # code — but it has real integration tests, so it is covered + gated like any - # other crate. Its only cross-crate files are none (standalone deps), so no - # cover_ignore_regex is needed. - { - "name": "fakeidp", - "lang": "rust", - "root": "src/backend", - "package": "fakeidp", - "paths": ["src/backend/services/fakeidp"], - }, # routegen is the build-time gateway config compiler (gateway DESIGN # DD-GW-02); fmt + clippy + coverage run here. Golden + rejection tests cover # the emitter/validator; tests/cli.rs drives the built binary end to end @@ -240,12 +229,7 @@ }, # `src/frontend/helm` falls under this path but has no measured lines, so it # never moves the number. - { - "name": "frontend", - "lang": "js", - "root": "src/frontend", - "paths": ["src/frontend"], - }, + {"name": "frontend", "lang": "js", "root": "src/frontend", "paths": ["src/frontend"]}, ] diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 96cca3a06..2c46b140d 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -340,7 +340,7 @@ dependencies = [ "openidconnect", "opentelemetry 0.32.0", "p256 0.14.0", - "rand 0.10.2", + "rand 0.10.1", "rdkafka", "redis", "reqwest 0.12.28", @@ -929,7 +929,7 @@ dependencies = [ "jsonwebtoken", "opentelemetry 0.31.0", "parking_lot", - "rand 0.10.2", + "rand 0.10.1", "regex", "reqwest 0.13.4", "secrecy", @@ -1219,7 +1219,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "pin-project-lite", - "rand 0.10.2", + "rand 0.10.1", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -1298,7 +1298,7 @@ checksum = "66f6c0dc95df1ffea29417dcd0c30918a2114d8cdd7578e8fcd5a11eceedff61" dependencies = [ "anyhow", "cf-gears-toolkit-security", - "rand 0.10.2", + "rand 0.10.1", "tokio", "tokio-stream", "tokio-util", @@ -2228,26 +2228,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fakeidp" -version = "0.1.0" -dependencies = [ - "axum", - "base64 0.23.0", - "jsonwebtoken", - "rand 0.10.2", - "reqwest 0.12.28", - "rsa", - "serde", - "serde_json", - "serde_yaml", - "sha2 0.11.0", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "fancy-regex" version = "0.17.0" @@ -4612,7 +4592,7 @@ dependencies = [ "bytes", "getrandom 0.4.2", "lru-slab", - "rand 0.10.2", + "rand 0.10.1", "rand_pcg", "ring", "rustc-hash", @@ -4689,9 +4669,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 5a307cad2..6f4cb1609 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -5,9 +5,6 @@ members = [ "services/analytics", "services/identity-resolution", "services/authenticator", - # Dev/e2e-only fake OIDC provider (never shipped in a prod image). See - # services/fakeidp/README.md and cf/NGINX_BFF.md §10 G6. - "services/fakeidp", # Build-time CLI: compiles the gateway routes.yaml into nginx.conf # (gateway DESIGN DD-GW-02). Never shipped in a runtime image. "tools/routegen", @@ -85,12 +82,12 @@ reqwest = { version = "0.12", features = ["json"] } # Caching redis = { version = "1.5", features = ["tokio-comp", "connection-manager"] } -# JWT (gateway-JWT mint/verify, RFC 7523 assertions, fakeidp id_tokens). +# JWT (gateway-JWT mint/verify, RFC 7523 assertions). # One version + one crypto provider (aws_lc_rs) across the workspace: v10 needs # exactly one provider process-wide under feature unification, else it panics. jsonwebtoken = { version = "10", default-features = false, features = ["aws_lc_rs", "use_pem"] } -# YAML (route configs, fakeidp users, test fixtures). +# YAML (route configs, test fixtures). serde_yaml = "0.9" # CLI diff --git a/src/backend/services/analytics/Dockerfile b/src/backend/services/analytics/Dockerfile index 8656a3dc7..0fecc720e 100644 --- a/src/backend/services/analytics/Dockerfile +++ b/src/backend/services/analytics/Dockerfile @@ -27,9 +27,6 @@ COPY services/authenticator/Cargo.toml ./services/authenticator/Cargo.toml # identity-resolution is a workspace member; cargo must load its manifest to # resolve the workspace even though this image never builds or ships it. COPY services/identity-resolution/Cargo.toml ./services/identity-resolution/Cargo.toml -# fakeidp is a dev/e2e workspace member; cargo must load its manifest to -# resolve the workspace even though this image never builds or ships it. -COPY services/fakeidp/Cargo.toml ./services/fakeidp/Cargo.toml # routegen is a workspace member (build-time CLI); cargo must load its manifest # to resolve the workspace even though this image never builds it. COPY tools/routegen/Cargo.toml ./tools/routegen/Cargo.toml @@ -39,7 +36,6 @@ RUN mkdir -p libs/insight-clickhouse/src && echo "" > libs/insight-clickhouse/sr mkdir -p services/analytics/src && echo "fn main() {}" > services/analytics/src/main.rs && \ mkdir -p services/authenticator/src && echo "fn main() {}" > services/authenticator/src/main.rs && \ mkdir -p services/identity-resolution/src && echo "fn main() {}" > services/identity-resolution/src/main.rs && \ - mkdir -p services/fakeidp/src && echo "fn main() {}" > services/fakeidp/src/main.rs && \ mkdir -p tools/routegen/src && echo "fn main() {}" > tools/routegen/src/main.rs RUN cargo build --release --bin analytics 2>/dev/null || true diff --git a/src/backend/services/authenticator/Dockerfile b/src/backend/services/authenticator/Dockerfile index 421611b92..d64ff5753 100644 --- a/src/backend/services/authenticator/Dockerfile +++ b/src/backend/services/authenticator/Dockerfile @@ -26,7 +26,6 @@ COPY services/authenticator/Cargo.toml ./services/authenticator/Cargo.toml # identity-resolution is a workspace member; cargo must load its manifest to # resolve the workspace even though this image never builds or ships it. COPY services/identity-resolution/Cargo.toml ./services/identity-resolution/Cargo.toml -COPY services/fakeidp/Cargo.toml ./services/fakeidp/Cargo.toml # routegen is a workspace member (build-time CLI); cargo must load its manifest # to resolve the workspace even though this image never builds it. COPY tools/routegen/Cargo.toml ./tools/routegen/Cargo.toml @@ -36,8 +35,6 @@ RUN mkdir -p libs/insight-clickhouse/src && echo "" > libs/insight-clickhouse/sr mkdir -p services/analytics/src && echo "fn main() {}" > services/analytics/src/main.rs && \ mkdir -p services/authenticator/src && echo "fn main() {}" > services/authenticator/src/main.rs && \ mkdir -p services/identity-resolution/src && echo "fn main() {}" > services/identity-resolution/src/main.rs && \ - mkdir -p services/fakeidp/src && echo "fn main() {}" > services/fakeidp/src/main.rs && \ - echo "" > services/fakeidp/src/lib.rs && \ mkdir -p tools/routegen/src && echo "fn main() {}" > tools/routegen/src/main.rs RUN cargo build --release --bin authenticator 2>/dev/null || true diff --git a/src/backend/services/authenticator/config/insight.yaml b/src/backend/services/authenticator/config/insight.yaml index 9964b0ab5..4336ab42d 100644 --- a/src/backend/services/authenticator/config/insight.yaml +++ b/src/backend/services/authenticator/config/insight.yaml @@ -134,8 +134,8 @@ gears: client_id: "" client_secret: "" # id_token claim naming the user's single tenant (string; an array is - # tolerated — first entry wins). fakeidp/Keycloak emit `tenant_id`; - # Entra emits `tid`. + # tolerated — first entry wins). Keycloak emits `tenant_id`; Entra + # emits `tid`. tenant_claim: "tenant_id" # The identity-resolution source_type this IdP is seeded under (e.g. # "ms-entra") — required, drives the login-bootstrap person lookup. diff --git a/src/backend/services/authenticator/src/config.rs b/src/backend/services/authenticator/src/config.rs index 765f85703..c5e0915e1 100644 --- a/src/backend/services/authenticator/src/config.rs +++ b/src/backend/services/authenticator/src/config.rs @@ -54,8 +54,8 @@ pub struct IdpConfig { /// Confidential-client secret (injected per-deployment; never committed). pub client_secret: String, /// id_token claim naming the user's single tenant. A plain string (an - /// array is tolerated: first entry wins). fakeidp/Keycloak emit - /// `tenant_id`; Entra emits `tid`. + /// array is tolerated: first entry wins). Keycloak emits `tenant_id`; + /// Entra emits `tid`. pub tenant_claim: String, /// The `insight_source_type` this IdP is known to identity-resolution as /// (e.g. `ms-entra`) — the connector whose `identity_inputs` seed the @@ -68,8 +68,8 @@ pub struct IdpConfig { /// `source_type` — the join key `identity_inputs` seeded it under (e.g. /// Entra's `oid`; the generic OIDC `sub` is NOT the same thing for /// directory-backed IdPs, see the `ms-entra` connector schema). Defaults - /// to `sub` (fine for IdPs, like fakeidp, where `sub` IS the stable - /// directory id). + /// to `sub` (fine for IdPs where `sub` IS the stable directory id, e.g. + /// Keycloak). pub external_id_claim: String, /// Fallback tenant when the id_token carries no tenant claim at all (e.g. /// Okta). Empty = no fallback: the gateway JWT gets an empty `tenant_id` @@ -470,9 +470,8 @@ impl AuthenticatorConfig { ); // Required fields (all injected per-deployment). `idp.client_secret` is - // intentionally optional — public OIDC clients (e.g. the dev fakeidp) - // authenticate with PKCE and no secret. `redis_url` is checked in - // SessionManager::connect. + // intentionally optional — public OIDC clients authenticate with PKCE + // and no secret. `redis_url` is checked in SessionManager::connect. for (name, value) in [ ("gateway_issuer", &self.gateway_issuer), ("redirect_uri", &self.redirect_uri), diff --git a/src/backend/services/authenticator/src/identity.rs b/src/backend/services/authenticator/src/identity.rs index dfe92bd93..755d7264f 100644 --- a/src/backend/services/authenticator/src/identity.rs +++ b/src/backend/services/authenticator/src/identity.rs @@ -13,8 +13,7 @@ //! an empty/absent value, so a login that lacks its external id fails closed //! instead of silently falling through to email resolution; //! - the single `tenant_id` is sourced from the validated id_token claim -//! (fakeidp supplies -//! it; real-IdP tenant-membership resolution is a follow-up — +//! (real-IdP tenant-membership resolution is a follow-up — //! constructorfabric/insight#1687); //! - an unknown person is denied (the callback returns 403). First-admin //! bootstrap / RBAC are out of step-04 scope (a separate universe-admin diff --git a/src/backend/services/authenticator/src/oidc.rs b/src/backend/services/authenticator/src/oidc.rs index fba17dd5b..3f4c668dd 100644 --- a/src/backend/services/authenticator/src/oidc.rs +++ b/src/backend/services/authenticator/src/oidc.rs @@ -291,7 +291,7 @@ impl OidcClient { // Non-standard claims read from the already-validated payload. One and // only one tenant per token (EPIC #1583): the claim name is per-IdP - // (`tenant_id` on fakeidp/Keycloak, `tid` on Entra); claim-less IdPs + // (`tenant_id` on Keycloak, `tid` on Entra); claim-less IdPs // (Okta) fall back to the configured default tenant; empty = downstream // fails closed. let raw = id_token.to_string(); @@ -460,7 +460,7 @@ impl OidcClient { } /// Read the single tenant from an (already-validated) compact JWT payload. -/// Accepts a plain string (`tenant_id` on fakeidp/Keycloak, `tid` on Entra); a +/// Accepts a plain string (`tenant_id` on Keycloak, `tid` on Entra); a /// string array is tolerated by taking its first entry (a Keycloak multivalued /// mapper). Anything else yields empty (→ fail closed downstream). fn payload_tenant(jwt: &str, field: &str) -> String { @@ -569,11 +569,11 @@ mod tests { #[test] fn external_id_defaults_to_sub() { // idp.external_id_claim defaults to "sub" — no extra claim needed - // (fakeidp, and any IdP where `sub` IS the stable directory id). - let jwt = jwt_with(&serde_json::json!({"sub": "fakeidp|dev"})); + // for IdPs where `sub` IS the stable directory id. + let jwt = jwt_with(&serde_json::json!({"sub": "idp|dev-lead"})); assert_eq!( - extract_external_id(&jwt, "sub", "fakeidp|dev").as_deref(), - Some("fakeidp|dev") + extract_external_id(&jwt, "sub", "idp|dev-lead").as_deref(), + Some("idp|dev-lead") ); } @@ -587,10 +587,10 @@ mod tests { // and that matching on `ResolveTarget` (not string emptiness) is what // selects the lookup mode. let login = crate::identity::IdpIdentity { - sub: "fakeidp|dev".to_owned(), + sub: "idp|dev-lead".to_owned(), email: "dev@company.nonpresent".to_owned(), tenant_id: "t1".to_owned(), - resolve_by: crate::identity::ResolveTarget::ExternalId("fakeidp|dev".to_owned()), + resolve_by: crate::identity::ResolveTarget::ExternalId("idp|dev-lead".to_owned()), }; let override_target = crate::identity::IdpIdentity { sub: String::new(), @@ -600,7 +600,7 @@ mod tests { }; assert!(matches!( login.resolve_by, - crate::identity::ResolveTarget::ExternalId(ref v) if v == "fakeidp|dev" + crate::identity::ResolveTarget::ExternalId(ref v) if v == "idp|dev-lead" )); assert!(matches!( override_target.resolve_by, diff --git a/src/backend/services/authenticator/tests/common/kc.rs b/src/backend/services/authenticator/tests/common/kc.rs index 983315ade..3b04362c9 100644 --- a/src/backend/services/authenticator/tests/common/kc.rs +++ b/src/backend/services/authenticator/tests/common/kc.rs @@ -1,5 +1,6 @@ //! Keycloak-side helpers for the e2e rig: drive the realm's login form and -//! the admin API — the seams fakeidp's `/_control/*` hooks used to provide. +//! the admin API — the seams the suites use to provoke IdP-side events +//! (logout, revocation, outage) that no client-side call can trigger. //! //! `run-e2e.sh` provides the coordinates: `E2E_KC_BASE` (the container's //! published origin), `E2E_KC_REALM`, `E2E_KC_CONTAINER` (for the docker @@ -197,8 +198,8 @@ async fn user_representation( /// Log the user out at the IdP (every session). With the client's /// back-channel logout URL registered, Keycloak fires a signed -/// `logout_token` per session at the authenticator — the real-IdP -/// equivalent of fakeidp's `/_control/backchannel/{email}` hook. +/// `logout_token` per session at the authenticator — the IdP-initiated +/// event the back-channel suite needs to provoke. pub async fn logout_user(email: &str) { let http = http(); let token = admin_token(&http).await; @@ -222,9 +223,8 @@ pub async fn logout_user(email: &str) { } /// Enable or disable the user at the IdP. A disabled user's next refresh -/// gets a definitive `invalid_grant` — the real-IdP equivalent of -/// fakeidp's `/_control/revoke/{user}` hook, without the back-channel -/// side-channel an admin logout would also fire. +/// gets a definitive `invalid_grant` — a revocation verdict without the +/// back-channel side-channel an admin logout would also fire. pub async fn set_user_enabled(email: &str, enabled: bool) { let http = http(); let token = admin_token(&http).await; @@ -247,7 +247,7 @@ pub async fn set_user_enabled(email: &str, enabled: bool) { /// Freeze the IdP container until the returned guard drops: requests hang /// until the client's own timeout and fail as transport errors — a transient -/// outage, the real-IdP equivalent of fakeidp's `/_control/outage` hook. +/// IdP outage, indistinguishable from a network partition. /// The guard thaws on drop, panic included, so a failed assertion mid-outage /// cannot leave the shared container paused for whoever runs next. pub fn idp_outage() -> IdpOutage { diff --git a/src/backend/services/authenticator/tests/e2e_refresher.rs b/src/backend/services/authenticator/tests/e2e_refresher.rs index 8eab70a46..64c6a2208 100644 --- a/src/backend/services/authenticator/tests/e2e_refresher.rs +++ b/src/backend/services/authenticator/tests/e2e_refresher.rs @@ -8,8 +8,8 @@ //! cargo test -p authenticator --test e2e_refresher -- --ignored --nocapture //! ``` //! -//! Drives the two IdP failure modes through real-IdP seams (tests/common/kc.rs; -//! what fakeidp's `/_control/*` hooks used to simulate): a paused container — +//! Drives the two IdP failure modes through real-IdP seams +//! (tests/common/kc.rs): a paused container — //! transient failures must log nobody out; and an admin-disabled user — the //! definitive `invalid_grant` verdict must kill the user's sessions on the //! next scheduled refresh, while another user's session survives. diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index 924ac652f..b5bdbf57e 100755 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -11,9 +11,9 @@ # (`insight-seed-realm`, from src/ingestion/tools/seed) with the rig's overlay # on top # (tests/kc-realm-overlay.py: test users, fast token lifespan, back-channel -# registration, and a second realm for the host-keyed issuer map). What the -# retired fakeidp offered as `/_control/*` hooks the suites now drive through -# the Keycloak admin API and `docker pause` (tests/common/kc.rs). +# registration, and a second realm for the host-keyed issuer map). IdP-side +# events the suites need to provoke (logout, revocation, outage) are driven +# through the Keycloak admin API and `docker pause` (tests/common/kc.rs). # # Everything runs on localhost, so no IdP-URL rewriting is needed. Usage: # src/backend/services/authenticator/tests/run-e2e.sh @@ -266,7 +266,7 @@ if ! wait_ready authenticator3 "http://localhost:$AUTH3_PORT/.well-known/jwks.js fi # Keycloak coordinates for the suites (tests/common/kc.rs): the login form -# password and the admin-API/docker seams that replaced fakeidp's hooks. +# password and the admin-API/docker seams for IdP-side events. export E2E_KC_BASE="$KC_BASE" export E2E_KC_REALM="$KC_REALM" export E2E_KC_CONTAINER="$KC_CT" diff --git a/src/backend/services/fakeidp/Cargo.toml b/src/backend/services/fakeidp/Cargo.toml deleted file mode 100644 index b9a3ef619..000000000 --- a/src/backend/services/fakeidp/Cargo.toml +++ /dev/null @@ -1,52 +0,0 @@ -# fakeidp — a deliberately silly fake OIDC provider for dev/e2e. -# -# NOT a toolkit gear, NOT production code, NEVER shipped in a production -# image or referenced by a production chart. It exists so the authenticator's -# *real* OIDC code path (code + PKCE, refresh-token rotation, back-channel -# logout, IdP-refusal handling) can be exercised end-to-end locally and in CI. -# -# See cf/NGINX_BFF.md §10 G6 for the decision and the control-hook rationale. -# -# Workspace lints are intentionally NOT inherited (`[lints] workspace = true` -# is absent): this is a throwaway test double, so the pedantic/`unwrap_used` -# denials the real services carry would be noise here. -[package] -name = "fakeidp" -version = "0.1.0" -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -rust-version.workspace = true -publish = false - -[[bin]] -name = "fakeidp" -path = "src/main.rs" - -[dependencies] -axum.workspace = true -tokio.workspace = true -serde.workspace = true -serde_json.workspace = true -uuid.workspace = true -reqwest.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true - -# JWT + YAML come from the shared [workspace.dependencies] table so the whole -# tree compiles one jsonwebtoken version + crypto provider (a mismatched v9 -# here would pull a second copy in). fakeidp signs RS256 id_tokens — supported -# by the workspace aws_lc_rs provider. -jsonwebtoken = { workspace = true } -serde_yaml = { workspace = true } -# Test-double-only deps (kept out of the shared table on purpose — nothing in -# production links these). -rsa = { version = "0.9", features = ["pem", "getrandom"] } -sha2 = "0.11" -base64 = "0.23" -rand = "0.10" - -[dev-dependencies] -reqwest = { workspace = true, features = ["json"] } -tokio = { workspace = true } diff --git a/src/backend/services/fakeidp/Dockerfile b/src/backend/services/fakeidp/Dockerfile deleted file mode 100644 index 5b4437310..000000000 --- a/src/backend/services/fakeidp/Dockerfile +++ /dev/null @@ -1,79 +0,0 @@ -# Multi-stage build for the fake IdP (dev/e2e ONLY). -# -# This image must NEVER be published to a production registry or referenced by -# a production chart — fakeidp signs tokens with a throwaway keypair generated -# at startup and exposes `/_control/*` test hooks. It exists so `docker compose up` -# and CI can drive the authenticator's real OIDC code path. See -# services/fakeidp/README.md and cf/NGINX_BFF.md §10 G6. -# -# Build context: src/backend/ -# Usage: cd src/backend && docker build -f services/fakeidp/Dockerfile -t insight-fakeidp:dev . - -# Stage 1: Builder -FROM rust:1.95-bookworm AS builder - -WORKDIR /build - -# --- Dependency caching layer --- -# fakeidp lives in the src/backend workspace, so cargo must be able to load -# every member's manifest. We copy just the manifests + stub sources so the -# dependency graph resolves and fakeidp's deps get cached without dragging in -# the other services' real code. -COPY Cargo.toml Cargo.lock ./ -COPY libs/insight-clickhouse/Cargo.toml ./libs/insight-clickhouse/Cargo.toml -COPY libs/authenticator-sdk/Cargo.toml ./libs/authenticator-sdk/Cargo.toml -COPY services/analytics/Cargo.toml ./services/analytics/Cargo.toml -COPY services/authenticator/Cargo.toml ./services/authenticator/Cargo.toml -# identity-resolution is a workspace member; cargo must load its manifest to -# resolve the workspace even though this image never builds or ships it. -COPY services/identity-resolution/Cargo.toml ./services/identity-resolution/Cargo.toml -COPY services/fakeidp/Cargo.toml ./services/fakeidp/Cargo.toml -# routegen is a workspace member (build-time CLI); cargo must load its manifest -# to resolve the workspace even though this image never builds it. -COPY tools/routegen/Cargo.toml ./tools/routegen/Cargo.toml - -RUN mkdir -p libs/insight-clickhouse/src && echo "" > libs/insight-clickhouse/src/lib.rs && \ - mkdir -p libs/authenticator-sdk/src && echo "" > libs/authenticator-sdk/src/lib.rs && \ - mkdir -p services/analytics/src && echo "fn main() {}" > services/analytics/src/main.rs && \ - mkdir -p services/authenticator/src && echo "fn main() {}" > services/authenticator/src/main.rs && \ - mkdir -p services/identity-resolution/src && echo "fn main() {}" > services/identity-resolution/src/main.rs && \ - mkdir -p services/fakeidp/src && echo "fn main() {}" > services/fakeidp/src/main.rs && \ - echo "" > services/fakeidp/src/lib.rs && \ - mkdir -p tools/routegen/src && echo "fn main() {}" > tools/routegen/src/main.rs - -RUN cargo build --release --bin fakeidp 2>/dev/null || true - -# --- Source layer --- -RUN rm -rf services/fakeidp/src \ - target/release/fakeidp target/release/deps/fakeidp* target/release/deps/libfakeidp* - -# The default users.yaml is baked into the binary via include_str!, so the -# source tree must carry it at compile time. The signing keypair is generated -# at startup — nothing to copy. -COPY services/fakeidp/src/ ./services/fakeidp/src/ -COPY services/fakeidp/users.yaml ./services/fakeidp/users.yaml - -RUN cargo build --release --bin fakeidp - -# Stage 2: Runtime -FROM debian:bookworm-slim - -# ca-certificates so the back-channel control hook can POST over TLS if the RP -# endpoint is https. -RUN apt-get update && \ - apt-get install -y --no-install-recommends ca-certificates && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY --from=builder /build/target/release/fakeidp /app/fakeidp -# Also drop users.yaml alongside the binary for reference / optional override -# via FAKEIDP_USERS (the binary bakes a copy, so this is not required to run). -COPY --from=builder /build/services/fakeidp/users.yaml /app/users.yaml - -RUN useradd -U -u 1000 -m appuser && chown -R 1000:1000 /app -USER 1000 - -EXPOSE 8084 - -ENTRYPOINT ["/app/fakeidp"] diff --git a/src/backend/services/fakeidp/README.md b/src/backend/services/fakeidp/README.md deleted file mode 100644 index c7f746427..000000000 --- a/src/backend/services/fakeidp/README.md +++ /dev/null @@ -1,156 +0,0 @@ -# fakeidp — a deliberately silly fake OIDC provider (dev/e2e only) - -`fakeidp` is a tiny [axum](https://github.com/tokio-rs/axum) binary that fakes -*just enough* of a customer OIDC provider to drive the **authenticator**'s real -login code path — authorization-code + PKCE, rotating refresh tokens, -RP-initiated and back-channel logout — with **no login screen and no external -IdP**. It also exposes `/_control/*` hooks that an off-the-shelf IdP can't give -us, so e2e tests can force the hard paths (IdP refusal, back-channel logout, -token-endpoint outages). - -> **This is a test double. It generates a throwaway RS256 signing key at startup -> and lets anyone mint a session for any test user. It must NEVER run in -> production, ship in a production image, or be referenced by a production -> chart.** See `cf/NGINX_BFF.md` §10 G6 for the decision. - -fakeidp is the **default** IdP behind the compose stack's `authenticator` BFF -(`AUTH_MODE=fakeidp` in `.env.compose`). For the real-login alternative -(`AUTH_MODE=keycloak`), see -[`deploy/compose/keycloak/README.md`](../../../../deploy/compose/keycloak/README.md). - -## Why this is NOT a gears-rust toolkit gear (by intent, not by mistake) - -Every other backend service here is an idiomatic gears-rust gear -(`#[toolkit::gear]`, OperationBuilder, CanonicalError, the global type system). -**fakeidp is deliberately a plain axum binary and deliberately does not use the -toolkit.** This is a conscious choice, not an oversight or unfinished work: - -- The requirement (NGINX_BFF.md §10 G6) is literally *"as silly as it can be"* — - a few hundred lines with zero framework ceremony, so it starts fast in CI and - is trivial to read and throw away. -- It fakes the **customer's** IdP — an external, third-party system. Modelling - someone else's OIDC provider as one of our gears would be a category error. -- It ships in **no** production image and is referenced by **no** production - chart, so none of the toolkit's operational guarantees (auth, tenancy, GTS, - canonical errors) buy anything here — they would only add weight. - -If you are tempted to "upgrade" it to a gear: don't. The silliness is the point. - -## Running it - -```sh -# Compose (default dev profile — comes up with the stack): -docker compose up fakeidp # → http://localhost:8084 - -# …or straight from source: -cd src/backend -cargo run -p fakeidp # → http://localhost:8084 -``` - -### Configuration (all optional, via env) - -| Env var | Default | Meaning | -|--------------------------|---------------------------|----------------------------------------------------------------| -| `FAKEIDP_ISSUER` | `http://localhost:8084` | OIDC issuer; also the `iss` claim. Use `http://fakeidp:8084` when consumed from inside compose. | -| `FAKEIDP_BIND` | `0.0.0.0:8084` | Listen address. | -| `FAKEIDP_TOKEN_TTL` | `300` | `expires_in` and id_token lifetime, in seconds. | -| `FAKEIDP_BACKCHANNEL_URL`| _(unset)_ | RP back-channel logout endpoint; required for `/_control/backchannel`. | -| `FAKEIDP_DEFAULT_AUD` | `authenticator` | `aud` for the back-channel `logout_token`. | -| `FAKEIDP_USERS` | _(baked `users.yaml`)_ | Path to an alternate users file. | -| `FAKEIDP_DEV_USER_EMAIL` | _(unset)_ | Overrides the **first** user's email — the default-login identity. Compose wires it from `DEV_USER_EMAIL`. | - -Test users live in [`users.yaml`](./users.yaml) (baked into the binary). The -**first** user is the default when `/authorize` is called with no `user=` -parameter. Its baked email (`dev@company.nonpresent`) matches dev-compose.sh's -`DEV_USER_EMAIL` default — the same dev person the seeder writes into -identity — so a plain `docker compose up` + login resolves to a real person. -When the wizard is given a different dev email, compose forwards it via -`FAKEIDP_DEV_USER_EMAIL` so the default login still matches the seeded person. - -The signing key is **generated fresh at each startup** — nothing is checked in. -Clients fetch the current public key from `GET /jwks`. - -## Endpoints - -| Endpoint | Purpose | -|---|---| -| `GET /.well-known/openid-configuration` | Discovery document. | -| `GET /jwks` | Public signing key (RS256). | -| `GET /authorize` | No login screen: mints a one-time code and 302s back to `redirect_uri`. | -| `POST /token` | `authorization_code` (+ PKCE) and `refresh_token` (rotating) grants. | -| `GET\|POST /end_session` | RP-initiated logout; 302s to `post_logout_redirect_uri`. | -| `POST /_control/revoke/{email}` | All future refreshes for that user → `invalid_grant`. | -| `POST /_control/backchannel/{email}` | POSTs a signed `logout_token` to `FAKEIDP_BACKCHANNEL_URL`. | -| `POST /_control/outage` | `{"mode":"off"\|"5xx"\|"timeout"}` — makes `/token` misbehave. | -| `GET /_control/state` | Debug dump: users, revoked set, outstanding codes / refresh tokens. | - -## Full code + PKCE login (copy-paste) - -A complete login against a running fakeidp on port 8084. Requires `curl`, -`openssl`, and `jq`. - -```sh -BASE=http://localhost:8084 - -# 1. Generate a PKCE verifier + S256 challenge. -VERIFIER=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64) -CHALLENGE=$(printf '%s' "$VERIFIER" \ - | openssl dgst -binary -sha256 \ - | openssl base64 -A | tr '+/' '-_' | tr -d '=') - -# 2. /authorize → 302 with a one-time code (no login screen). Grab the code -# out of the Location header. Add `&user=bob@example.com` to log in as -# someone other than the default first user. -LOCATION=$(curl -sS -o /dev/null -D - \ - "$BASE/authorize?client_id=authenticator&redirect_uri=http://localhost/callback&state=xyz&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256" \ - | tr -d '\r' | awk '/^location:/i {print $2}') -CODE=$(printf '%s' "$LOCATION" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p') -echo "code = $CODE" - -# 3. Exchange the code (with the PKCE verifier) for tokens. -TOKENS=$(curl -sS -X POST "$BASE/token" \ - -d grant_type=authorization_code \ - -d "code=$CODE" \ - -d "code_verifier=$VERIFIER" \ - -d redirect_uri=http://localhost/callback \ - -d client_id=authenticator) -echo "$TOKENS" | jq . -REFRESH=$(echo "$TOKENS" | jq -r .refresh_token) - -# 4. Refresh — the refresh token ROTATES (one-time use). -curl -sS -X POST "$BASE/token" \ - -d grant_type=refresh_token -d "refresh_token=$REFRESH" | jq . - -# 5. Reusing the OLD refresh token now fails closed: -curl -sS -X POST "$BASE/token" \ - -d grant_type=refresh_token -d "refresh_token=$REFRESH" | jq . -# → {"error":"invalid_grant", ...} -``` - -Decode the `id_token` (it is a normal RS256 JWT) at your favourite JWT viewer, -or verify it against `GET /jwks`. - -### Exercising the control hooks - -```sh -# Force IdP refusal: every future refresh for alice returns invalid_grant -# (the authenticator must then kill all of her linked sessions). -curl -sS -X POST "$BASE/_control/revoke/alice@example.com" - -# Simulate a token-endpoint outage, then turn it back off. -curl -sS -X POST "$BASE/_control/outage" -H 'content-type: application/json' -d '{"mode":"5xx"}' -curl -sS -X POST "$BASE/_control/outage" -H 'content-type: application/json' -d '{"mode":"off"}' - -# Fire a back-channel logout at the RP (needs FAKEIDP_BACKCHANNEL_URL set). -curl -sS -X POST "$BASE/_control/backchannel/alice@example.com" - -# Peek at internal state. -curl -sS "$BASE/_control/state" | jq . -``` - -## Tests - -```sh -cd src/backend -cargo test -p fakeidp # full code+PKCE login, refresh rotation, revoke kill path -``` diff --git a/src/backend/services/fakeidp/helm/Chart.yaml b/src/backend/services/fakeidp/helm/Chart.yaml deleted file mode 100644 index 2aff3b37c..000000000 --- a/src/backend/services/fakeidp/helm/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: insight-fakeidp -description: Insight fake OIDC provider — LOCAL/CI IdP for the nginx+auth stack (never for production) -version: 0.1.0 -appVersion: "0.1.0" -type: application diff --git a/src/backend/services/fakeidp/helm/templates/_helpers.tpl b/src/backend/services/fakeidp/helm/templates/_helpers.tpl deleted file mode 100644 index 56ea33506..000000000 --- a/src/backend/services/fakeidp/helm/templates/_helpers.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{{- define "insight-fakeidp.fullname" -}} -{{ .Release.Name }}-fakeidp -{{- end }} - -{{- define "insight-fakeidp.labels" -}} -app.kubernetes.io/name: fakeidp -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{- define "insight-fakeidp.selectorLabels" -}} -app.kubernetes.io/name: fakeidp -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} diff --git a/src/backend/services/fakeidp/helm/templates/deployment.yaml b/src/backend/services/fakeidp/helm/templates/deployment.yaml deleted file mode 100644 index 79334b8dc..000000000 --- a/src/backend/services/fakeidp/helm/templates/deployment.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "insight-fakeidp.fullname" . }} - labels: - {{- include "insight-fakeidp.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.replicaCount }} - selector: - matchLabels: - {{- include "insight-fakeidp.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "insight-fakeidp.selectorLabels" . | nindent 8 }} - spec: - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - containers: - - name: fakeidp - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - name: http - containerPort: {{ .Values.service.port }} - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] - env: - - name: FAKEIDP_ISSUER - value: {{ tpl (required "fakeidp.issuer is required (the in-cluster fakeidp URL)" .Values.issuer) . | quote }} - - name: FAKEIDP_BIND - value: "0.0.0.0:{{ .Values.service.port }}" - - name: FAKEIDP_DEFAULT_AUD - value: {{ .Values.defaultAudience | quote }} - - name: FAKEIDP_DEV_USER_EMAIL - value: {{ .Values.devUserEmail | quote }} - - name: FAKEIDP_TOKEN_TTL - value: {{ .Values.tokenTtlSeconds | quote }} - resources: - {{- toYaml .Values.resources | nindent 12 }} - livenessProbe: - {{- toYaml .Values.livenessProbe | nindent 12 }} - readinessProbe: - {{- toYaml .Values.readinessProbe | nindent 12 }} diff --git a/src/backend/services/fakeidp/helm/templates/ingress.yaml b/src/backend/services/fakeidp/helm/templates/ingress.yaml deleted file mode 100644 index 3bc108e7b..000000000 --- a/src/backend/services/fakeidp/helm/templates/ingress.yaml +++ /dev/null @@ -1,38 +0,0 @@ -{{- if .Values.ingress.enabled }} -# Cluster ingress -> the fakeidp OIDC provider, served under the /idp prefix. -# fakeidp serves its OIDC routes at ROOT (/.well-known/openid-configuration, -# /authorize, /token, /jwks) and its discovery document emits ABSOLUTE URLs -# ({issuer}/authorize, {issuer}/token, {issuer}/jwks). So to expose it under a -# path prefix we strip the prefix with an nginx rewrite: the capture group -# `(.*)` after `/idp(/|$)` is `$2`, and rewrite-target rewrites the request to -# `/$2` before it hits the Service. The browser and the authenticator pod both -# reach it at `/idp`, which must equal `.Values.issuer`. -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ include "insight-fakeidp.fullname" . }} - labels: - {{- include "insight-fakeidp.labels" . | nindent 4 }} - annotations: - nginx.ingress.kubernetes.io/rewrite-target: /$2 - {{- with .Values.ingress.annotations }} - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if .Values.ingress.className }} - ingressClassName: {{ .Values.ingress.className }} - {{- end }} - rules: - - {{- if .Values.ingress.host }} - host: {{ .Values.ingress.host | quote }} - {{- end }} - http: - paths: - - path: /idp(/|$)(.*) - pathType: ImplementationSpecific - backend: - service: - name: {{ include "insight-fakeidp.fullname" . }} - port: - name: http -{{- end }} diff --git a/src/backend/services/fakeidp/helm/templates/service.yaml b/src/backend/services/fakeidp/helm/templates/service.yaml deleted file mode 100644 index dc5c773f8..000000000 --- a/src/backend/services/fakeidp/helm/templates/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "insight-fakeidp.fullname" . }} - labels: - {{- include "insight-fakeidp.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "insight-fakeidp.selectorLabels" . | nindent 4 }} diff --git a/src/backend/services/fakeidp/helm/values.yaml b/src/backend/services/fakeidp/helm/values.yaml deleted file mode 100644 index 98cab2fd7..000000000 --- a/src/backend/services/fakeidp/helm/values.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# insight-fakeidp — a LOCAL/CI-only OIDC provider (the authenticator's IdP when -# no real IdP is configured). NEVER enable in production: the umbrella gates it -# behind `fakeidp.enabled` and refuses real deployments to use it. -replicaCount: 1 - -image: - repository: ghcr.io/constructorfabric/insight-fakeidp - tag: "" # umbrella sets this to the pinned appVersion - pullPolicy: IfNotPresent - -service: - type: ClusterIP - port: 8084 - -# Optional ingress. Disabled by default (in-cluster-only). When enabled, the -# fakeidp is exposed at the /idp path prefix; an nginx rewrite strips the prefix -# (the chart always injects `rewrite-target: /$2`, merged with any annotations -# below) because fakeidp serves its OIDC routes at ROOT and emits absolute URLs. -ingress: - enabled: false - className: nginx - host: "" - annotations: {} - -# Issuer the authenticator validates the id_token `iss` against + fetches -# discovery/JWKS/token from. Must equal the in-cluster URL the authenticator -# uses (set by the umbrella to the fakeidp Service FQDN). -issuer: "" -# Audience of issued id_tokens = the authenticator's OIDC client_id. -defaultAudience: "insight-authenticator" -# Default login identity (dev impersonation person; must be seeded in identity). -devUserEmail: "dev@company.nonpresent" -tokenTtlSeconds: 300 - -resources: - requests: {cpu: 25m, memory: 32Mi} - limits: {cpu: 200m, memory: 128Mi} - -livenessProbe: - httpGet: {path: /.well-known/openid-configuration, port: http} -readinessProbe: - httpGet: {path: /.well-known/openid-configuration, port: http} diff --git a/src/backend/services/fakeidp/src/lib.rs b/src/backend/services/fakeidp/src/lib.rs deleted file mode 100644 index 76f818854..000000000 --- a/src/backend/services/fakeidp/src/lib.rs +++ /dev/null @@ -1,807 +0,0 @@ -//! fakeidp — a deliberately silly fake OIDC provider for dev and e2e. -//! -//! It implements *just enough* OIDC (discovery, JWKS, an instant `/authorize` -//! with no login screen, a `/token` endpoint with authorization-code + PKCE and -//! rotating one-time-use refresh tokens, and RP-initiated logout) to drive the -//! authenticator's real code path — plus a set of `/_control/*` hooks that an -//! off-the-shelf IdP can't give us: forcing `invalid_grant` on refresh, firing a -//! back-channel `logout_token`, and simulating token-endpoint outages. -//! -//! It is NOT a toolkit gear and never ships in a production image. See -//! `cf/NGINX_BFF.md` §10 G6 for the decision and §4.1 for the flows it exercises. -//! -//! Everything lives in one process, in memory, behind one mutex. That is the -//! point — "as silly as it can be". -//! -//! The binary (`src/main.rs`) is a thin wrapper over [`run`]; the guts live -//! here so the integration test can build the same [`app`] router in-process. - -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use axum::{ - Json, Router, - extract::{Form, Path, Query, State}, - http::{StatusCode, header}, - response::{IntoResponse, Response}, - routing::{get, post}, -}; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; -use rand::Rng as _; -use rsa::RsaPrivateKey; -use rsa::pkcs8::{EncodePrivateKey, LineEnding}; -use rsa::traits::PublicKeyParts; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -const KID: &str = "fakeidp-key-1"; - -/// Baked default users, used when `FAKEIDP_USERS` is not set (so `cargo run` -/// and the container both work with zero setup). -const DEFAULT_USERS_YAML: &str = include_str!("../users.yaml"); - -// ─── Config ──────────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct Config { - pub issuer: String, - pub bind: String, - pub token_ttl: u64, - /// Back-channel logout endpoint of the authenticator (the RP). Only used by - /// `POST /_control/backchannel/{email}`. - pub backchannel_url: Option, - /// `aud` used for the back-channel `logout_token` (the code flow uses the - /// per-request `client_id` instead). - pub default_aud: String, -} - -impl Config { - pub fn from_env() -> Self { - let issuer = - std::env::var("FAKEIDP_ISSUER").unwrap_or_else(|_| "http://localhost:8084".into()); - Self { - issuer, - bind: std::env::var("FAKEIDP_BIND").unwrap_or_else(|_| "0.0.0.0:8084".into()), - token_ttl: std::env::var("FAKEIDP_TOKEN_TTL") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(300), - backchannel_url: std::env::var("FAKEIDP_BACKCHANNEL_URL") - .ok() - .filter(|s| !s.is_empty()), - default_aud: std::env::var("FAKEIDP_DEFAULT_AUD") - .unwrap_or_else(|_| "authenticator".into()), - } - } -} - -// ─── Users ─────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct User { - pub email: String, - pub name: String, - pub sub: String, - pub sid: String, - /// The user's single tenant (single-tenant token contract, EPIC #1583). - #[serde(default)] - pub tenant_id: String, -} - -#[derive(Deserialize)] -struct UsersFile { - users: Vec, -} - -/// Load test users from `FAKEIDP_USERS` (a path) or fall back to the baked -/// `users.yaml`. Panics on malformed input — this is a test binary; loud is fine. -/// -/// If `FAKEIDP_DEV_USER_EMAIL` is set, it overrides the *first* user's email. -/// Compose wires this from the wizard's `DEV_USER_EMAIL`, so the default -/// login always matches the dev person the seeder wrote into identity — even -/// when the operator picked a non-default dev email. -pub fn load_users() -> Vec { - let raw = match std::env::var("FAKEIDP_USERS") { - Ok(path) => { - std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("FAKEIDP_USERS={path}: {e}")) - } - Err(_) => DEFAULT_USERS_YAML.to_string(), - }; - let parsed: UsersFile = serde_yaml::from_str(&raw).expect("users.yaml is not valid YAML"); - let mut users = parsed.users; - assert!( - !users.is_empty(), - "users.yaml must define at least one user" - ); - - if let Ok(email) = std::env::var("FAKEIDP_DEV_USER_EMAIL") - && !email.is_empty() - { - users[0].email = email; - } - users -} - -// ─── Mutable state ───────────────────────────────────────────────────────── - -/// A one-time authorization code, bound to the login context so `/token` can -/// validate PKCE and mint the right id_token. -struct AuthCode { - email: String, - nonce: Option, - code_challenge: Option, - code_challenge_method: Option, - client_id: String, - used: bool, -} - -/// A refresh token. Rotation flips `active` to false on the old token so reuse -/// is detectable (→ `invalid_grant`) rather than silently accepted. -struct RefreshEntry { - email: String, - sid: String, - /// The `client_id` from the original authorize request, so rotated ID - /// tokens keep the same `aud` an RP with a non-default client would expect. - client_id: String, - active: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Outage { - Off, - ServerError, - Timeout, -} - -#[derive(Default)] -struct Mutable { - codes: HashMap, - refresh_tokens: HashMap, - revoked: HashSet, - outage: Option, -} - -pub struct AppState { - config: Config, - users: Vec, - signing_key: EncodingKey, - jwks: Value, - inner: Mutex, -} - -pub type Shared = Arc; - -impl AppState { - pub fn new(config: Config, users: Vec) -> Self { - let (signing_key, jwks) = generate_signing_material(); - Self { - config, - users, - signing_key, - jwks, - inner: Mutex::new(Mutable::default()), - } - } - - fn user_by_email(&self, email: &str) -> Option<&User> { - self.users.iter().find(|u| u.email == email) - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Mutable> { - self.inner.lock().expect("state mutex poisoned") - } -} - -// ─── Crypto helpers ────────────────────────────────────────────────────── - -/// Generate a fresh RS256 keypair at startup and return the signing key plus a -/// matching JWKS derived from it, so `/jwks` and the signer can never drift. -/// -/// Nothing is persisted: the key lives only in this process. It fakes the -/// *customer* IdP (whose id_tokens the authenticator verifies via our JWKS), -/// not our own gateway JWT, and consumers fetch `/jwks` at runtime — so a fresh -/// key per boot is exactly right and keeps key material out of the repo. -fn generate_signing_material() -> (EncodingKey, Value) { - let mut rng = rsa::rand_core::OsRng; - let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA key"); - let pem = priv_key - .to_pkcs8_pem(LineEnding::LF) - .expect("encode generated key as PKCS#8 PEM"); - let signing_key = - EncodingKey::from_rsa_pem(pem.as_bytes()).expect("generated key is valid RSA PEM"); - - let pub_key = priv_key.to_public_key(); - let n = URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be()); - let e = URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be()); - let jwks = json!({ - "keys": [{ - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": KID, - "n": n, - "e": e, - }] - }); - (signing_key, jwks) -} - -fn now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock is before 1970") - .as_secs() -} - -/// Opaque, unguessable token (authorization code / access token / refresh token). -fn opaque() -> String { - let mut bytes = [0u8; 32]; - rand::rng().fill_bytes(&mut bytes); - URL_SAFE_NO_PAD.encode(bytes) -} - -/// RFC 7636 PKCE verification. Returns Ok(()) when the code carried no -/// challenge (PKCE not used), or when the verifier matches. -fn verify_pkce( - challenge: &Option, - method: &Option, - verifier: &Option, -) -> Result<(), &'static str> { - let Some(challenge) = challenge else { - return Ok(()); - }; - let verifier = verifier.as_deref().ok_or("code_verifier required")?; - let method = method.as_deref().unwrap_or("plain"); - let computed = match method { - "plain" => verifier.to_string(), - "S256" => { - let digest = Sha256::digest(verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest) - } - _ => return Err("unsupported code_challenge_method"), - }; - if computed == *challenge { - Ok(()) - } else { - Err("PKCE verification failed") - } -} - -// ─── Token minting ───────────────────────────────────────────────────────── - -#[derive(Serialize)] -struct IdTokenClaims<'a> { - iss: &'a str, - sub: &'a str, - aud: &'a str, - exp: u64, - iat: u64, - #[serde(skip_serializing_if = "Option::is_none")] - nonce: Option, - email: &'a str, - name: &'a str, - sid: &'a str, - /// The single tenant from users.yaml (one and only one tenant per token). - /// Always emitted (possibly empty) for predictability. - tenant_id: &'a str, -} - -fn sign_id_token(state: &AppState, user: &User, aud: &str, nonce: Option) -> String { - let iat = now(); - let claims = IdTokenClaims { - iss: &state.config.issuer, - sub: &user.sub, - aud, - exp: iat + state.config.token_ttl, - iat, - nonce, - email: &user.email, - name: &user.name, - sid: &user.sid, - tenant_id: &user.tenant_id, - }; - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(KID.to_string()); - encode(&header, &claims, &state.signing_key).expect("id_token signing") -} - -#[derive(Serialize)] -struct LogoutTokenClaims<'a> { - iss: &'a str, - aud: &'a str, - iat: u64, - jti: String, - sub: &'a str, - sid: &'a str, - events: Value, -} - -fn sign_logout_token(state: &AppState, user: &User) -> String { - let claims = LogoutTokenClaims { - iss: &state.config.issuer, - aud: &state.config.default_aud, - iat: now(), - jti: uuid::Uuid::now_v7().to_string(), - sub: &user.sub, - sid: &user.sid, - events: json!({ "http://schemas.openid.net/event/backchannel-logout": {} }), - }; - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(KID.to_string()); - encode(&header, &claims, &state.signing_key).expect("logout_token signing") -} - -#[derive(Serialize)] -struct TokenResponse { - access_token: String, - token_type: &'static str, - expires_in: u64, - refresh_token: String, - id_token: String, - scope: String, -} - -fn oauth_error(status: StatusCode, error: &str, desc: &str) -> Response { - ( - status, - Json(json!({ "error": error, "error_description": desc })), - ) - .into_response() -} - -// ─── OIDC endpoints ────────────────────────────────────────────────────── - -async fn discovery(State(state): State) -> Json { - let iss = &state.config.issuer; - Json(json!({ - "issuer": iss, - "authorization_endpoint": format!("{iss}/authorize"), - "token_endpoint": format!("{iss}/token"), - "jwks_uri": format!("{iss}/jwks"), - "end_session_endpoint": format!("{iss}/end_session"), - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "scopes_supported": ["openid", "email", "profile", "offline_access"], - "code_challenge_methods_supported": ["S256", "plain"], - "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], - })) -} - -async fn jwks(State(state): State) -> Json { - Json(state.jwks.clone()) -} - -#[derive(Deserialize)] -struct AuthorizeParams { - client_id: Option, - redirect_uri: String, - state: Option, - nonce: Option, - code_challenge: Option, - code_challenge_method: Option, - /// Which test user to log in as. Defaults to the first user in users.yaml. - user: Option, -} - -/// No login screen: pick the requested (or default) user, mint a one-time code -/// bound to (user, nonce, PKCE challenge), and 302 straight back to the RP. -async fn authorize(State(state): State, Query(params): Query) -> Response { - let email = match ¶ms.user { - Some(email) => email.clone(), - None => state.users[0].email.clone(), - }; - if state.user_by_email(&email).is_none() { - return oauth_error( - StatusCode::BAD_REQUEST, - "access_denied", - &format!("unknown test user: {email}"), - ); - } - - let code = opaque(); - state.lock().codes.insert( - code.clone(), - AuthCode { - email, - nonce: params.nonce, - code_challenge: params.code_challenge, - code_challenge_method: params.code_challenge_method, - client_id: params - .client_id - .unwrap_or_else(|| state.config.default_aud.clone()), - used: false, - }, - ); - - let sep = if params.redirect_uri.contains('?') { - '&' - } else { - '?' - }; - let mut location = format!("{}{}code={}", params.redirect_uri, sep, urlencode(&code)); - if let Some(st) = ¶ms.state { - location.push_str(&format!("&state={}", urlencode(st))); - } - redirect(&location) -} - -#[derive(Deserialize)] -struct TokenRequest { - grant_type: String, - // authorization_code grant - code: Option, - code_verifier: Option, - // refresh_token grant - refresh_token: Option, - #[serde(default)] - scope: Option, -} - -async fn token(State(state): State, Form(req): Form) -> Response { - // Outage simulation happens before any grant logic — the token endpoint - // is the thing we want to misbehave (G6 / transient-vs-definitive test). - // Read the mode into a local so the (non-Send) MutexGuard is dropped - // before the `.await` in the timeout arm. - let outage = state.lock().outage.unwrap_or(Outage::Off); - match outage { - Outage::Off => {} - Outage::ServerError => { - return oauth_error( - StatusCode::SERVICE_UNAVAILABLE, - "temporarily_unavailable", - "simulated outage", - ); - } - Outage::Timeout => { - tokio::time::sleep(Duration::from_secs(60)).await; - return oauth_error( - StatusCode::GATEWAY_TIMEOUT, - "temporarily_unavailable", - "simulated timeout", - ); - } - } - - let scope = req - .scope - .clone() - .unwrap_or_else(|| "openid email profile".into()); - match req.grant_type.as_str() { - "authorization_code" => token_code_grant(&state, &req, &scope), - "refresh_token" => token_refresh_grant(&state, &req, &scope), - other => oauth_error( - StatusCode::BAD_REQUEST, - "unsupported_grant_type", - &format!("unsupported grant_type: {other}"), - ), - } -} - -fn token_code_grant(state: &AppState, req: &TokenRequest, scope: &str) -> Response { - let Some(code) = &req.code else { - return oauth_error(StatusCode::BAD_REQUEST, "invalid_request", "code required"); - }; - - // Consume the code (one-time) under the lock, capturing what we need. - let (email, nonce, client_id) = { - let mut guard = state.lock(); - let Some(entry) = guard.codes.get_mut(code) else { - return oauth_error(StatusCode::BAD_REQUEST, "invalid_grant", "unknown code"); - }; - if entry.used { - return oauth_error( - StatusCode::BAD_REQUEST, - "invalid_grant", - "code already used", - ); - } - if let Err(msg) = verify_pkce( - &entry.code_challenge, - &entry.code_challenge_method, - &req.code_verifier, - ) { - return oauth_error(StatusCode::BAD_REQUEST, "invalid_grant", msg); - } - entry.used = true; - ( - entry.email.clone(), - entry.nonce.clone(), - entry.client_id.clone(), - ) - }; - - let Some(user) = state.user_by_email(&email) else { - return oauth_error(StatusCode::BAD_REQUEST, "invalid_grant", "user vanished"); - }; - let user = user.clone(); - - let id_token = sign_id_token(state, &user, &client_id, nonce); - let refresh_token = opaque(); - state.lock().refresh_tokens.insert( - refresh_token.clone(), - RefreshEntry { - email: user.email.clone(), - sid: user.sid.clone(), - client_id, - active: true, - }, - ); - - Json(TokenResponse { - access_token: opaque(), - token_type: "Bearer", - expires_in: state.config.token_ttl, - refresh_token, - id_token, - scope: scope.to_string(), - }) - .into_response() -} - -fn token_refresh_grant(state: &AppState, req: &TokenRequest, scope: &str) -> Response { - let Some(token) = &req.refresh_token else { - return oauth_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "refresh_token required", - ); - }; - - // Validate + rotate under one lock: reuse of a rotated token, an unknown - // token, or a revoked user all fail closed with invalid_grant. - let (email, client_id, new_refresh) = { - let mut guard = state.lock(); - let Some(entry) = guard.refresh_tokens.get(token) else { - return oauth_error( - StatusCode::BAD_REQUEST, - "invalid_grant", - "unknown refresh_token", - ); - }; - if !entry.active { - return oauth_error( - StatusCode::BAD_REQUEST, - "invalid_grant", - "refresh_token already used (rotation reuse)", - ); - } - let email = entry.email.clone(); - let sid = entry.sid.clone(); - let client_id = entry.client_id.clone(); - if guard.revoked.contains(&email) { - // Retire the token so the debug dump reflects the kill. - if let Some(e) = guard.refresh_tokens.get_mut(token) { - e.active = false; - } - return oauth_error( - StatusCode::BAD_REQUEST, - "invalid_grant", - "user revoked at the IdP", - ); - } - // Rotate: retire the old token, mint a new active one. - if let Some(e) = guard.refresh_tokens.get_mut(token) { - e.active = false; - } - let new_refresh = opaque(); - guard.refresh_tokens.insert( - new_refresh.clone(), - RefreshEntry { - email: email.clone(), - sid, - client_id: client_id.clone(), - active: true, - }, - ); - (email, client_id, new_refresh) - }; - - let Some(user) = state.user_by_email(&email) else { - return oauth_error(StatusCode::BAD_REQUEST, "invalid_grant", "user vanished"); - }; - let user = user.clone(); - - // Reuse the original client's audience so a non-default RP still accepts - // the rotated ID token. - let id_token = sign_id_token(state, &user, &client_id, None); - Json(TokenResponse { - access_token: opaque(), - token_type: "Bearer", - expires_in: state.config.token_ttl, - refresh_token: new_refresh, - id_token, - scope: scope.to_string(), - }) - .into_response() -} - -#[derive(Deserialize)] -struct EndSessionParams { - post_logout_redirect_uri: Option, - state: Option, -} - -/// RP-initiated logout target: just 302 to the requested URI (or 200 if none). -async fn end_session(Query(params): Query) -> Response { - match params.post_logout_redirect_uri { - Some(uri) => { - let location = match params.state { - Some(st) => { - let sep = if uri.contains('?') { '&' } else { '?' }; - format!("{uri}{sep}state={}", urlencode(&st)) - } - None => uri, - }; - redirect(&location) - } - None => (StatusCode::OK, "logged out").into_response(), - } -} - -// ─── Test-control hooks (the reason fakeidp exists) ──────────────────────── - -/// All future refresh attempts for `{email}` return `invalid_grant`, so e2e can -/// assert the authenticator kills every linked session (the G5 refuse path). -async fn control_revoke(State(state): State, Path(email): Path) -> Response { - if state.user_by_email(&email).is_none() { - return oauth_error(StatusCode::NOT_FOUND, "unknown_user", "no such test user"); - } - state.lock().revoked.insert(email.clone()); - (StatusCode::OK, Json(json!({ "revoked": email }))).into_response() -} - -/// Fire a signed back-channel `logout_token` at the configured RP endpoint. -async fn control_backchannel(State(state): State, Path(email): Path) -> Response { - let Some(user) = state.user_by_email(&email) else { - return oauth_error(StatusCode::NOT_FOUND, "unknown_user", "no such test user"); - }; - let Some(url) = state.config.backchannel_url.clone() else { - return oauth_error( - StatusCode::PRECONDITION_FAILED, - "not_configured", - "FAKEIDP_BACKCHANNEL_URL is not set", - ); - }; - let logout_token = sign_logout_token(&state, user); - - // Bounded: a stalled RP must not hang the control hook indefinitely. - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .expect("reqwest client builds"); - let resp = client - .post(&url) - .form(&[("logout_token", logout_token.as_str())]) - .send() - .await; - match resp { - Ok(r) => ( - StatusCode::OK, - Json(json!({ "sent_to": url, "rp_status": r.status().as_u16() })), - ) - .into_response(), - Err(e) => oauth_error( - StatusCode::BAD_GATEWAY, - "backchannel_failed", - &format!("POST to {url} failed: {e}"), - ), - } -} - -#[derive(Deserialize)] -struct OutageBody { - mode: String, -} - -async fn control_outage(State(state): State, Json(body): Json) -> Response { - let mode = match body.mode.as_str() { - "off" => Outage::Off, - "5xx" => Outage::ServerError, - "timeout" => Outage::Timeout, - other => { - return oauth_error( - StatusCode::BAD_REQUEST, - "invalid_mode", - &format!("mode must be off|5xx|timeout, got {other}"), - ); - } - }; - state.lock().outage = Some(mode); - (StatusCode::OK, Json(json!({ "outage": body.mode }))).into_response() -} - -/// Debug dump: users, the revoked set, and outstanding codes / refresh tokens. -async fn control_state(State(state): State) -> Json { - let guard = state.lock(); - let codes: Vec = guard - .codes - .iter() - .map(|(code, c)| json!({ "code": code, "email": c.email, "used": c.used })) - .collect(); - let refresh_tokens: Vec = guard - .refresh_tokens - .iter() - .map(|(t, r)| json!({ "token": t, "email": r.email, "active": r.active })) - .collect(); - let outage = match guard.outage.unwrap_or(Outage::Off) { - Outage::Off => "off", - Outage::ServerError => "5xx", - Outage::Timeout => "timeout", - }; - Json(json!({ - "users": state.users.iter().map(|u| &u.email).collect::>(), - "revoked": guard.revoked.iter().collect::>(), - "outage": outage, - "codes": codes, - "refresh_tokens": refresh_tokens, - })) -} - -// ─── Small helpers ───────────────────────────────────────────────────────── - -fn redirect(location: &str) -> Response { - ( - StatusCode::FOUND, - [(header::LOCATION, location.to_string())], - ) - .into_response() -} - -/// Minimal percent-encoding for the handful of characters that break a query -/// value (`code`/`state` are our own opaque tokens, so this is enough). -fn urlencode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char); - } - _ => out.push_str(&format!("%{b:02X}")), - } - } - out -} - -/// Build the router. Public so the integration test can serve it in-process. -pub fn app(state: Shared) -> Router { - Router::new() - .route("/.well-known/openid-configuration", get(discovery)) - .route("/jwks", get(jwks)) - .route("/authorize", get(authorize)) - .route("/token", post(token)) - .route("/end_session", get(end_session).post(end_session)) - .route("/_control/revoke/{email}", post(control_revoke)) - .route("/_control/backchannel/{email}", post(control_backchannel)) - .route("/_control/outage", post(control_outage)) - .route("/_control/state", get(control_state)) - .with_state(state) -} - -/// Wire up logging, load config + users, bind, and serve. The binary calls this. -pub async fn run() { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) - .init(); - - let config = Config::from_env(); - let bind = config.bind.clone(); - let issuer = config.issuer.clone(); - let users = load_users(); - let state = Arc::new(AppState::new(config, users)); - - let listener = tokio::net::TcpListener::bind(&bind) - .await - .unwrap_or_else(|e| panic!("bind {bind}: {e}")); - tracing::info!(%bind, %issuer, "fakeidp listening (this is a TEST double — never run it in prod)"); - axum::serve(listener, app(state)) - .await - .expect("server error"); -} diff --git a/src/backend/services/fakeidp/src/main.rs b/src/backend/services/fakeidp/src/main.rs deleted file mode 100644 index 43086c5f8..000000000 --- a/src/backend/services/fakeidp/src/main.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! fakeidp binary — a thin wrapper over the library's [`fakeidp::run`]. -//! -//! All logic lives in `lib.rs` so the integration test can drive the same -//! router in-process. This is a dev/e2e test double; see the crate docs. - -#[tokio::main] -async fn main() { - fakeidp::run().await; -} diff --git a/src/backend/services/fakeidp/tests/boot.rs b/src/backend/services/fakeidp/tests/boot.rs deleted file mode 100644 index bb699025f..000000000 --- a/src/backend/services/fakeidp/tests/boot.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Covers the env-driven entrypoint (`Config::from_env` + `run`) and the -//! `FAKEIDP_DEV_USER_EMAIL` override by actually booting the server the way the -//! binary does. Kept in its own test file so it runs as a separate process and -//! never races `flow.rs` on `std::env`. - -use std::time::Duration; - -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use serde_json::Value; - -const DEV_EMAIL: &str = "wizard-dev@example.test"; - -#[tokio::test] -async fn boots_from_env_and_honors_dev_user_override() { - // Grab a free port, then point the env at it so `run()` binds somewhere - // predictable (and we avoid colliding with a real service on 8084). - let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let port = probe.local_addr().unwrap().port(); - drop(probe); - let issuer = format!("http://127.0.0.1:{port}"); - - // SAFETY: set before any other thread reads the environment; this is the - // only test in this process and it mutates env once, up front. - unsafe { - std::env::set_var("FAKEIDP_BIND", format!("127.0.0.1:{port}")); - std::env::set_var("FAKEIDP_ISSUER", &issuer); - std::env::set_var("FAKEIDP_TOKEN_TTL", "123"); - std::env::set_var("FAKEIDP_DEFAULT_AUD", "authenticator"); - std::env::set_var("FAKEIDP_DEV_USER_EMAIL", DEV_EMAIL); - } - - tokio::spawn(fakeidp::run()); - - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap(); - - // Wait for the server (started via run()) to come up. - let mut booted = false; - for _ in 0..100 { - if let Ok(resp) = client - .get(format!("{issuer}/.well-known/openid-configuration")) - .send() - .await - && resp.status().is_success() - { - let body: Value = resp.json().await.unwrap(); - assert_eq!(body["issuer"], issuer, "issuer comes from FAKEIDP_ISSUER"); - booted = true; - break; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - assert!(booted, "fakeidp did not come up via run()"); - - // Default login (no `user=`) → the first user, whose email must be the - // FAKEIDP_DEV_USER_EMAIL override rather than the baked default. - let authz = client - .get(format!("{issuer}/authorize")) - .query(&[("redirect_uri", "http://rp.test/cb")]) - .send() - .await - .unwrap(); - let location = authz.headers()["location"].to_str().unwrap(); - let code = location - .split_once('?') - .unwrap() - .1 - .split('&') - .find_map(|kv| kv.strip_prefix("code=")) - .unwrap() - .to_string(); - let tok: Value = client - .post(format!("{issuer}/token")) - .form(&[("grant_type", "authorization_code"), ("code", &code)]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - - let payload = tok["id_token"].as_str().unwrap().split('.').nth(1).unwrap(); - let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap(); - assert_eq!( - claims["email"], DEV_EMAIL, - "dev user email override applied" - ); - assert_eq!(tok["expires_in"], 123, "token_ttl comes from env"); -} diff --git a/src/backend/services/fakeidp/tests/flow.rs b/src/backend/services/fakeidp/tests/flow.rs deleted file mode 100644 index 4e76bc827..000000000 --- a/src/backend/services/fakeidp/tests/flow.rs +++ /dev/null @@ -1,699 +0,0 @@ -//! End-to-end integration tests for fakeidp, driving the real HTTP handlers -//! in-process on ephemeral ports via the library's `app()` router: -//! -//! * the full authorization-code + PKCE login, refresh-token rotation, and the -//! `_control/revoke` kill path; -//! * discovery / JWKS; -//! * every `/token` and `/authorize` error branch (unknown/used code, PKCE -//! failures, unsupported grant, missing/unknown refresh token); -//! * `/end_session`, and all four `/_control/*` hooks (revoke, back-channel -//! with a stub RP, outage modes, state dump). -//! -//! Env-driven paths (`Config::from_env`, `run`) are covered separately in -//! `tests/boot.rs` (its own process, so it never races these on `std::env`). - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use axum::{Router, extract::State, routing::post}; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use fakeidp::{AppState, Config, app, load_users}; -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -const ISSUER: &str = "http://fakeidp.test"; -const AUD: &str = "authenticator"; - -fn config(backchannel_url: Option) -> Config { - Config { - issuer: ISSUER.to_string(), - bind: "127.0.0.1:0".to_string(), - token_ttl: 300, - backchannel_url, - default_aud: AUD.to_string(), - } -} - -async fn spawn_with(cfg: Config) -> String { - let state = Arc::new(AppState::new(cfg, load_users())); - serve(app(state)).await -} - -async fn spawn() -> String { - spawn_with(config(None)).await -} - -/// Bind an ephemeral port, serve `router` in the background, return the base URL. -async fn serve(router: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - format!("http://{addr}") -} - -fn no_redirect_client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() -} - -fn pkce_pair() -> (String, String) { - let verifier = "test-verifier-0123456789-abcdefghijklmnopqrstuvwxyz".to_string(); - let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); - (verifier, challenge) -} - -fn code_from_location(location: &str) -> String { - let query = location.split_once('?').expect("redirect has a query").1; - query - .split('&') - .find_map(|kv| kv.strip_prefix("code=")) - .expect("redirect carries a code") - .to_string() -} - -/// Decode a JWT payload without verifying the signature — for tests that only -/// assert claim values. -fn unverified_claims(jwt: &str) -> Value { - let payload = jwt.split('.').nth(1).expect("jwt has a payload segment"); - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap() -} - -/// Run the S256 `/authorize` (as `user`) → grab code → exchange it for tokens, -/// returning the parsed token response. Shared by several tests. -async fn login(base: &str, client: &reqwest::Client, user: &str) -> (Value, String) { - let (verifier, challenge) = pkce_pair(); - let authz = client - .get(format!("{base}/authorize")) - .query(&[ - ("client_id", AUD), - ("redirect_uri", "http://rp.test/callback"), - ("state", "xyz"), - ("nonce", "n1"), - ("code_challenge", &challenge), - ("code_challenge_method", "S256"), - ("user", user), - ]) - .send() - .await - .unwrap(); - assert_eq!(authz.status().as_u16(), 302); - let location = authz.headers()["location"].to_str().unwrap().to_string(); - let code = code_from_location(&location); - let tokens: Value = client - .post(format!("{base}/token")) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &code), - ("code_verifier", &verifier), - ("redirect_uri", "http://rp.test/callback"), - ("client_id", AUD), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - (tokens, verifier) -} - -#[tokio::test] -async fn full_login_refresh_rotation_and_revoke() { - let base = spawn().await; - let client = no_redirect_client(); - - // ── login: /authorize (302) → /token (code+PKCE) → signed id_token ──── - let (tok, _verifier) = login(&base, &client, "alice@example.com").await; - let id_token = tok["id_token"].as_str().unwrap(); - let refresh1 = tok["refresh_token"].as_str().unwrap().to_string(); - assert_eq!(tok["token_type"], "Bearer"); - assert_eq!(tok["expires_in"], 300); - assert!(!refresh1.is_empty()); - - // id_token must verify against the published JWKS with the right claims. - let jwks: Value = client - .get(format!("{base}/jwks")) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let n = jwks["keys"][0]["n"].as_str().unwrap(); - let e = jwks["keys"][0]["e"].as_str().unwrap(); - let key = DecodingKey::from_rsa_components(n, e).unwrap(); - let mut validation = Validation::new(Algorithm::RS256); - validation.set_audience(&[AUD]); - validation.set_issuer(&[ISSUER]); - let claims = decode::(id_token, &key, &validation).unwrap().claims; - assert_eq!(claims["email"], "alice@example.com"); - assert_eq!(claims["nonce"], "n1"); - assert!(claims["sub"].as_str().unwrap().starts_with("fakeidp|")); - // The single tenant from users.yaml is emitted for e2e to assert/map. - assert_eq!( - claims["tenant_id"], - serde_json::json!("00000000-df51-5b42-9538-d2b56b7ee953") - ); - - // ── refresh rotates; the old token then fails closed ───────────────── - let refreshed: Value = client - .post(format!("{base}/token")) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", &refresh1), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let refresh2 = refreshed["refresh_token"].as_str().unwrap().to_string(); - assert_ne!(refresh1, refresh2, "refresh token must rotate"); - assert!(!refreshed["id_token"].as_str().unwrap().is_empty()); - - let reuse = client - .post(format!("{base}/token")) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", &refresh1), - ]) - .send() - .await - .unwrap(); - assert_eq!(reuse.status().as_u16(), 400); - assert_eq!( - reuse.json::().await.unwrap()["error"], - "invalid_grant" - ); - - // ── revoke the user → the current (valid) refresh token also dies ───── - let revoke = client - .post(format!("{base}/_control/revoke/alice@example.com")) - .send() - .await - .unwrap(); - assert_eq!(revoke.status().as_u16(), 200); - - let after = client - .post(format!("{base}/token")) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", &refresh2), - ]) - .send() - .await - .unwrap(); - assert_eq!(after.status().as_u16(), 400); - assert_eq!( - after.json::().await.unwrap()["error"], - "invalid_grant" - ); -} - -#[tokio::test] -async fn discovery_and_jwks() { - let base = spawn().await; - let client = reqwest::Client::new(); - let disco: Value = client - .get(format!("{base}/.well-known/openid-configuration")) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(disco["issuer"], ISSUER); - assert_eq!(disco["token_endpoint"], format!("{ISSUER}/token")); - assert_eq!(disco["jwks_uri"], format!("{ISSUER}/jwks")); - assert!( - disco["code_challenge_methods_supported"] - .as_array() - .unwrap() - .contains(&Value::from("S256")) - ); - - let jwks: Value = client - .get(format!("{base}/jwks")) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(jwks["keys"][0]["kty"], "RSA"); - assert_eq!(jwks["keys"][0]["alg"], "RS256"); - assert_eq!(jwks["keys"][0]["kid"], "fakeidp-key-1"); -} - -#[tokio::test] -async fn authorize_selects_user_and_rejects_unknown() { - let base = spawn().await; - let client = no_redirect_client(); - - // Explicit user selection is honoured (bob, not the default alice). - let authz = client - .get(format!("{base}/authorize")) - .query(&[ - ("redirect_uri", "http://rp.test/cb?already=1"), - ("user", "bob@example.com"), - ]) - .send() - .await - .unwrap(); - assert_eq!(authz.status().as_u16(), 302); - // redirect_uri already had a query, so the code is appended with '&'. - let loc = authz.headers()["location"].to_str().unwrap(); - assert!( - loc.contains("already=1&code="), - "appends with & to existing query: {loc}" - ); - - // Unknown user is denied. - let denied = client - .get(format!("{base}/authorize")) - .query(&[ - ("redirect_uri", "http://rp.test/cb"), - ("user", "nobody@example.com"), - ]) - .send() - .await - .unwrap(); - assert_eq!(denied.status().as_u16(), 400); - assert_eq!( - denied.json::().await.unwrap()["error"], - "access_denied" - ); -} - -#[tokio::test] -async fn authorize_with_plain_pkce() { - let base = spawn().await; - let client = no_redirect_client(); - // method=plain → challenge == verifier. - let verifier = "plain-verifier-value"; - let authz = client - .get(format!("{base}/authorize")) - .query(&[ - ("redirect_uri", "http://rp.test/cb"), - ("code_challenge", verifier), - ("code_challenge_method", "plain"), - ]) - .send() - .await - .unwrap(); - let code = code_from_location(authz.headers()["location"].to_str().unwrap()); - let resp = client - .post(format!("{base}/token")) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &code), - ("code_verifier", verifier), - ]) - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 200); - assert!( - !resp.json::().await.unwrap()["id_token"] - .as_str() - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn token_error_branches() { - let base = spawn().await; - let client = no_redirect_client(); - - async fn err(client: &reqwest::Client, base: &str, form: &[(&str, &str)]) -> (u16, Value) { - let r = client - .post(format!("{base}/token")) - .form(form) - .send() - .await - .unwrap(); - let status = r.status().as_u16(); - (status, r.json().await.unwrap()) - } - - // Missing code. - let (s, b) = err(&client, &base, &[("grant_type", "authorization_code")]).await; - assert_eq!((s, &b["error"]), (400, &Value::from("invalid_request"))); - - // Unknown code. - let (s, b) = err( - &client, - &base, - &[("grant_type", "authorization_code"), ("code", "nope")], - ) - .await; - assert_eq!((s, &b["error"]), (400, &Value::from("invalid_grant"))); - - // Unsupported grant type. - let (s, b) = err(&client, &base, &[("grant_type", "password")]).await; - assert_eq!( - (s, &b["error"]), - (400, &Value::from("unsupported_grant_type")) - ); - - // Missing / unknown refresh token. - let (s, b) = err(&client, &base, &[("grant_type", "refresh_token")]).await; - assert_eq!((s, &b["error"]), (400, &Value::from("invalid_request"))); - let (s, b) = err( - &client, - &base, - &[("grant_type", "refresh_token"), ("refresh_token", "nope")], - ) - .await; - assert_eq!((s, &b["error"]), (400, &Value::from("invalid_grant"))); - - // PKCE: challenge present but no verifier, then wrong verifier, then a - // reused code — each fails invalid_grant. - let (_, challenge) = pkce_pair(); - let mint = |q: Vec<(&'static str, String)>| { - let client = client.clone(); - let base = base.clone(); - async move { - let a = client - .get(format!("{base}/authorize")) - .query(&q) - .send() - .await - .unwrap(); - code_from_location(a.headers()["location"].to_str().unwrap()) - } - }; - - let code = mint(vec![ - ("redirect_uri", "http://rp.test/cb".into()), - ("code_challenge", challenge.clone()), - ("code_challenge_method", "S256".into()), - ]) - .await; - // no verifier - let (s, b) = err( - &client, - &base, - &[("grant_type", "authorization_code"), ("code", &code)], - ) - .await; - assert_eq!((s, &b["error"]), (400, &Value::from("invalid_grant"))); - // wrong verifier (same code is still unused because PKCE is checked before - // the code is marked used) - let (s, _) = err( - &client, - &base, - &[ - ("grant_type", "authorization_code"), - ("code", &code), - ("code_verifier", "wrong"), - ], - ) - .await; - assert_eq!(s, 400); - - // Reused code: mint (no PKCE), spend it, spend it again → invalid_grant. - let code = mint(vec![("redirect_uri", "http://rp.test/cb".into())]).await; - let ok = client - .post(format!("{base}/token")) - .form(&[("grant_type", "authorization_code"), ("code", &code)]) - .send() - .await - .unwrap(); - assert_eq!(ok.status().as_u16(), 200); - let (s, b) = err( - &client, - &base, - &[("grant_type", "authorization_code"), ("code", &code)], - ) - .await; - assert_eq!((s, &b["error"]), (400, &Value::from("invalid_grant"))); - - // Unsupported PKCE method. - let code = mint(vec![ - ("redirect_uri", "http://rp.test/cb".into()), - ("code_challenge", "x".into()), - ("code_challenge_method", "S512".into()), - ]) - .await; - let (s, _) = err( - &client, - &base, - &[ - ("grant_type", "authorization_code"), - ("code", &code), - ("code_verifier", "x"), - ], - ) - .await; - assert_eq!(s, 400); -} - -#[tokio::test] -async fn end_session_redirects_or_ok() { - let base = spawn().await; - let client = no_redirect_client(); - - let r = client - .get(format!("{base}/end_session")) - .query(&[ - ("post_logout_redirect_uri", "http://rp.test/bye"), - ("state", "s1"), - ]) - .send() - .await - .unwrap(); - assert_eq!(r.status().as_u16(), 302); - assert_eq!(r.headers()["location"], "http://rp.test/bye?state=s1"); - - // No redirect uri → 200. - let r = client - .post(format!("{base}/end_session")) - .send() - .await - .unwrap(); - assert_eq!(r.status().as_u16(), 200); -} - -#[tokio::test] -async fn outage_modes_and_reset() { - let base = spawn().await; - let client = reqwest::Client::new(); - - // Invalid mode rejected. - let bad = client - .post(format!("{base}/_control/outage")) - .json(&serde_json::json!({"mode": "boom"})) - .send() - .await - .unwrap(); - assert_eq!(bad.status().as_u16(), 400); - - // 5xx mode → /token returns 503. - client - .post(format!("{base}/_control/outage")) - .json(&serde_json::json!({"mode": "5xx"})) - .send() - .await - .unwrap(); - let during = client - .post(format!("{base}/token")) - .form(&[("grant_type", "refresh_token"), ("refresh_token", "x")]) - .send() - .await - .unwrap(); - assert_eq!(during.status().as_u16(), 503); - - // Back off → /token works again (unknown token → 400, i.e. not 503). - client - .post(format!("{base}/_control/outage")) - .json(&serde_json::json!({"mode": "off"})) - .send() - .await - .unwrap(); - let after = client - .post(format!("{base}/token")) - .form(&[("grant_type", "refresh_token"), ("refresh_token", "x")]) - .send() - .await - .unwrap(); - assert_eq!(after.status().as_u16(), 400); -} - -#[tokio::test] -async fn control_state_and_revoke_unknown() { - let base = spawn().await; - // No-redirect client: /authorize 302s to an unreachable rp.test, and we - // only care about the mint side effect, not following the redirect. - let client = no_redirect_client(); - - // Mint a code so the state dump has something to show. - let _ = client - .get(format!("{base}/authorize")) - .query(&[("redirect_uri", "http://rp.test/cb")]) - .send() - .await - .unwrap(); - - let state: Value = client - .get(format!("{base}/_control/state")) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert!( - state["users"] - .as_array() - .unwrap() - .contains(&Value::from("alice@example.com")) - ); - assert_eq!(state["outage"], "off"); - assert_eq!(state["codes"].as_array().unwrap().len(), 1); - - // Revoking an unknown user is a 404. - let r = client - .post(format!("{base}/_control/revoke/ghost@example.com")) - .send() - .await - .unwrap(); - assert_eq!(r.status().as_u16(), 404); -} - -#[tokio::test] -async fn backchannel_hook() { - let client = reqwest::Client::new(); - - // Not configured → 412. - let base = spawn().await; - let r = client - .post(format!("{base}/_control/backchannel/alice@example.com")) - .send() - .await - .unwrap(); - assert_eq!(r.status().as_u16(), 412); - - // Unknown user → 404 (before the config check). - let r = client - .post(format!("{base}/_control/backchannel/ghost@example.com")) - .send() - .await - .unwrap(); - assert_eq!(r.status().as_u16(), 404); - - // Configured with a stub RP that captures the logout_token. - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let rp = Router::new() - .route("/bcl", post(rp_receiver)) - .with_state(captured.clone()); - let rp_base = serve(rp).await; - - let base = spawn_with(config(Some(format!("{rp_base}/bcl")))).await; - let ok = client - .post(format!("{base}/_control/backchannel/alice@example.com")) - .send() - .await - .unwrap(); - assert_eq!(ok.status().as_u16(), 200); - assert_eq!(ok.json::().await.unwrap()["rp_status"], 200); - - // The RP received a signed logout_token with the back-channel events claim. - let token = captured.lock().unwrap()[0].clone(); - let payload = token.split('.').nth(1).unwrap(); - let json: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap(); - assert_eq!(json["sid"], "sid-alice-0001"); - assert!( - json["events"] - .as_object() - .unwrap() - .contains_key("http://schemas.openid.net/event/backchannel-logout") - ); - - // Configured but the RP is unreachable → 502. - let base = spawn_with(config(Some("http://127.0.0.1:1/nope".into()))).await; - let bad = client - .post(format!("{base}/_control/backchannel/alice@example.com")) - .send() - .await - .unwrap(); - assert_eq!(bad.status().as_u16(), 502); -} - -#[tokio::test] -async fn refresh_preserves_non_default_audience() { - let base = spawn().await; - let client = no_redirect_client(); - - // Log in with a non-default client_id (no PKCE, for brevity). - let authz = client - .get(format!("{base}/authorize")) - .query(&[ - ("client_id", "spa-client"), - ("redirect_uri", "http://rp.test/cb"), - ("user", "bob@example.com"), - ]) - .send() - .await - .unwrap(); - let code = code_from_location(authz.headers()["location"].to_str().unwrap()); - let tok: Value = client - .post(format!("{base}/token")) - .form(&[ - ("grant_type", "authorization_code"), - ("code", &code), - ("client_id", "spa-client"), - ]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!( - unverified_claims(tok["id_token"].as_str().unwrap())["aud"], - "spa-client" - ); - - // The rotated ID token must keep the original client's audience, not fall - // back to the default. - let refresh = tok["refresh_token"].as_str().unwrap(); - let refreshed: Value = client - .post(format!("{base}/token")) - .form(&[("grant_type", "refresh_token"), ("refresh_token", refresh)]) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let claims = unverified_claims(refreshed["id_token"].as_str().unwrap()); - assert_eq!( - claims["aud"], "spa-client", - "audience preserved across refresh" - ); - assert_eq!( - claims["tenant_id"], - serde_json::json!("00000000-df51-5b42-9538-d2b56b7ee953"), - "bob's tenant present on refreshed token too" - ); -} - -async fn rp_receiver( - State(store): State>>>, - axum::extract::Form(form): axum::extract::Form>, -) -> &'static str { - store - .lock() - .unwrap() - .push(form.get("logout_token").cloned().unwrap_or_default()); - "ok" -} diff --git a/src/backend/services/fakeidp/users.yaml b/src/backend/services/fakeidp/users.yaml deleted file mode 100644 index d0a636849..000000000 --- a/src/backend/services/fakeidp/users.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# Test users for the fake IdP. Checked in, test-only, no secrets. -# -# The first entry is the default returned by /authorize when no `user=` -# query parameter is given. `sub` is the stable IdP subject; `sid` is the IdP -# session id echoed into id_tokens and back-channel logout_tokens. `tenant_id` -# is the user's single tenant (one and only one tenant per token, EPIC #1583) — -# the fake IdP carries it so e2e fixtures have something to assert on. -# -# The first user's email is the dev-impersonation identity. Its default here -# matches dev-compose.sh's DEV_USER_EMAIL default (dev@company.nonpresent), -# which is the same email the seeder writes into identity.persons — so a plain -# `docker compose up` + login "just works". Set FAKEIDP_DEV_USER_EMAIL (compose -# wires it from DEV_USER_EMAIL) to override this first user's email when -# the wizard was given a different value. -users: - - email: dev@company.nonpresent - name: Dev User - sub: fakeidp|dev - sid: sid-dev-0001 - tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953" - - - email: alice@example.com - name: Alice Admin - sub: fakeidp|alice - sid: sid-alice-0001 - tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953" - - - email: bob@example.com - name: Bob Builder - sub: fakeidp|bob - sid: sid-bob-0001 - tenant_id: "00000000-df51-5b42-9538-d2b56b7ee953" - - - email: carol@example.com - name: Carol Contractor - sub: fakeidp|carol - sid: sid-carol-0001 - tenant_id: "11111111-df51-5b42-9538-d2b56b7ee953" diff --git a/src/backend/services/gateway/tests/.gitignore b/src/backend/services/gateway/tests/.gitignore index 4bc1e9df3..433444ea8 100644 --- a/src/backend/services/gateway/tests/.gitignore +++ b/src/backend/services/gateway/tests/.gitignore @@ -1,4 +1,6 @@ # Generated / ephemeral e2e artifacts (produced by run-e2e.sh). nginx.conf keys/ +keycloak-import/ work/ +certs/ diff --git a/src/backend/services/gateway/tests/conftest.py b/src/backend/services/gateway/tests/conftest.py index 3dff58be8..d6186ddae 100644 --- a/src/backend/services/gateway/tests/conftest.py +++ b/src/backend/services/gateway/tests/conftest.py @@ -1,35 +1,71 @@ """Session orchestrator for the gateway e2e (NGINX_BFF step-05 scenarios). -Owns the compose stack lifecycle (real authenticator + fakeidp behind the +Owns the compose stack lifecycle (real authenticator + Keycloak behind the OpenResty gateway, with stub identity + echo upstream) and exposes a small HTTP client plus fixtures for the fail-closed scenarios (authenticator / upstream down). Tests live in test_gateway.py. -pytest runs on the host; the OIDC redirect chain uses in-network hostnames, so -the client rewrites them to the published localhost ports (see GatewayClient). +The IdP is a real Keycloak importing the generated roster realm +(`insight-seed-realm`, from src/ingestion/tools/seed — generated here into +./keycloak-import). pytest runs on the host; the OIDC redirect chain uses +in-network hostnames, so the client rewrites them to the published localhost +ports (see GatewayClient). """ from __future__ import annotations +import http.cookiejar import json +import os +import re +import shutil import subprocess import time import urllib.error +import urllib.parse import urllib.request from pathlib import Path import pytest HERE = Path(__file__).parent +REPO_ROOT = HERE.parents[4] COMPOSE = ["docker", "compose", "-f", str(HERE / "docker-compose.e2e.yml")] -CORE_SERVICES = ["redis", "identity-stub", "fakeidp", "authenticator", "echo", "gateway"] +CORE_SERVICES = ["redis", "identity-stub", "keycloak", "authenticator", "echo", "gateway"] GW = "http://localhost:18080" -FAKEIDP = "http://localhost:18084" +KEYCLOAK = "http://localhost:18084" AUTHENTICATOR = "http://localhost:18083" -# In-network hostnames the authenticator emits in redirects -> published ports. -REWRITES = {"http://gateway:8080": GW, "http://fakeidp:8084": FAKEIDP} +# In-network hostnames the authenticator and Keycloak emit in redirects and +# form actions -> published ports. +REWRITES = {"http://gateway:8080": GW, "http://keycloak:8085": KEYCLOAK} + +KC_REALM = "insight" +KC_DISCOVERY = f"{KEYCLOAK}/realms/{KC_REALM}/.well-known/openid-configuration" +# The realm generator's dev-lead persona; every realm user's password is the +# generator's baked dev password. +E2E_USER = "dev@company.nonpresent" +E2E_PASSWORD = "insight-dev" +# Every realm user carries a tenant claim and the generator requires one. This +# rig resolves people through the identity-stub (any external id resolves), so +# the value only has to be named; it is the one the compose stack uses. +TENANT_ID = "00000000-df51-5b42-9538-d2b56b7ee953" + +# Keycloak's login form posts to .../login-actions/authenticate. Matching on +# that rather than "the first
" survives extra forms on the page. +_LOGIN_FORM = re.compile(r']+action="([^"]*login-actions/authenticate[^"]*)"', re.IGNORECASE) + + +class _SendSecureOverHttp(http.cookiejar.DefaultCookiePolicy): + """Keycloak marks its auth-session cookies Secure even over plain http, and + the stdlib jar then refuses to send them back over the rig's published http + port — the credential POST would arrive session-less and 400. A browser at + http://localhost has no such problem (secure context).""" + + def return_ok_secure(self, cookie, request): + return True + # Must match the authenticator's authz_cache_max_age_seconds in the compose file. AUTHZ_CACHE_MAX_AGE = 3 @@ -41,16 +77,25 @@ def _compose(*args: str, check: bool = True) -> subprocess.CompletedProcess: class GatewayClient: """Minimal HTTP client: no auto-redirects, case-insensitive headers, and an - OIDC login helper that rewrites in-network redirect hosts to localhost.""" + OIDC login helper that drives Keycloak's HTML login form, rewriting + in-network redirect hosts to localhost.""" - def request(self, url, headers=None, method="GET"): - req = urllib.request.Request(url, headers=headers or {}, method=method) + def request(self, url, headers=None, method="GET", data=None, jar=None): + body = None + hdrs = dict(headers or {}) + if data is not None: + body = urllib.parse.urlencode(data).encode() + hdrs["Content-Type"] = "application/x-www-form-urlencoded" + req = urllib.request.Request(url, headers=hdrs, method=method, data=body) class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **k): return None - opener = urllib.request.build_opener(_NoRedirect) + handlers: list = [_NoRedirect] + if jar is not None: + handlers.append(urllib.request.HTTPCookieProcessor(jar)) + opener = urllib.request.build_opener(*handlers) try: resp = opener.open(req, timeout=15) return resp.status, self._lower(resp.headers), resp.read() @@ -68,9 +113,27 @@ def _rewrite(url): return url def login(self): - """Drive the OIDC code flow through the gateway; return the __Host-sid value.""" + """Drive the OIDC code flow through the gateway; return the __Host-sid value. + + Keycloak serves a real HTML login form, so the middle of the chain is: + GET the authorize URL (collecting the IdP's auth-session cookies), parse + the form action, POST the credentials, then deliver the code redirect to + the gateway callback. + """ _, h, _ = self.request(f"{GW}/auth/login?return_to=/") - _, h, _ = self.request(self._rewrite(h["location"])) # fakeidp /authorize + + jar = http.cookiejar.CookieJar(policy=_SendSecureOverHttp()) + status, _, body = self.request(self._rewrite(h["location"]), jar=jar) + assert status == 200, f"authorize expected the login form, got {status}" + match = _LOGIN_FORM.search(body.decode()) + assert match, "no Keycloak login form in the authorize response" + action = self._rewrite(match.group(1).replace("&", "&")) + + status, h, _ = self.request( + action, method="POST", data={"username": E2E_USER, "password": E2E_PASSWORD}, jar=jar + ) + assert status == 302, f"credential POST expected 302, got {status}" + status, h, _ = self.request(self._rewrite(h["location"])) # gateway /auth/callback assert status == 302, f"callback expected 302, got {status}" for part in h.get("set-cookie", "").split(";"): @@ -104,6 +167,33 @@ def _wait_http(url, want, timeout_s=90): raise TimeoutError(f"not ready: {url} (last={last})") +def _generate_realm(import_dir: Path) -> None: + """Generate the Keycloak import realm with `insight-seed-realm` (uv resolves + and installs the seed package on first use). The compose default redirect + URIs would deregister the gateway callback (--authenticator-redirect + REPLACES, not appends), so it is passed explicitly.""" + seed = REPO_ROOT / "src" / "ingestion" / "tools" / "seed" + import_dir.mkdir(exist_ok=True) + subprocess.run( + [ + "uv", + "run", + "--project", + str(seed), + "insight-seed-realm", + "--dev-email", + E2E_USER, + "--authenticator-redirect", + "http://gateway:8080/auth/callback", + "--out", + str(import_dir / "realm-insight.json"), + ], + check=True, + capture_output=True, + env={**os.environ, "TENANT_DEFAULT_ID": TENANT_ID}, + ) + + @pytest.fixture(scope="session", autouse=True) def stack(): """Build + start the compose stack for the whole session; tear down after.""" @@ -153,8 +243,14 @@ def _genpkey_ec(out: str) -> None: capture_output=True, ) (keys / "testclient.pub.pem").chmod(0o644) + kc_import = HERE / "keycloak-import" + _generate_realm(kc_import) try: _compose("up", "-d", "--build", *CORE_SERVICES) + # Keycloak start + realm import runs tens of seconds; the realm + # discovery document answers only once its import committed. The + # authenticator discovers per-op, so it needs no restart after this. + _wait_http(KC_DISCOVERY, want={200}, timeout_s=240) _wait_http(f"{GW}/healthz", want={200}) _wait_http(f"{GW}/auth/login", want={302}) # 302 once the authenticator is reachable yield @@ -164,6 +260,7 @@ def _genpkey_ec(out: str) -> None: for leftover in ("current.pem", "testclient.key.pem", "testclient.pub.pem"): (keys / leftover).unlink(missing_ok=True) keys.rmdir() + shutil.rmtree(kc_import, ignore_errors=True) @pytest.fixture diff --git a/src/backend/services/gateway/tests/docker-compose.e2e.yml b/src/backend/services/gateway/tests/docker-compose.e2e.yml index 31d5bf74d..5268e52d7 100644 --- a/src/backend/services/gateway/tests/docker-compose.e2e.yml +++ b/src/backend/services/gateway/tests/docker-compose.e2e.yml @@ -1,11 +1,11 @@ # Gateway e2e stack (dev/CI ONLY) -- NGINX_BFF.md §D. # -# The full edge: the real authenticator + fakeidp from steps 03-04 behind the +# The full edge: the real authenticator + a realm-importing Keycloak behind the # OpenResty gateway, with stub identity + echo upstream. Everything runs on one # compose network so DNS names resolve for both nginx upstreams and the Lua # cosocket (resolver 127.0.0.11), and the OIDC redirect flow works without URL -# rewriting. Driven by run-e2e.sh; assertions run from the `driver` service -# (on-network, so `gateway`/`fakeidp` resolve). +# rewriting inside the network. Driven by run-e2e.sh; the host-side pytest +# rewrites the in-network hostnames to the published ports (conftest.py). name: insight-gw-e2e services: @@ -24,19 +24,29 @@ services: - ../../authenticator/tests/identity-stub.py:/identity-stub.py:ro command: ["python", "/identity-stub.py", "0.0.0.0:8092"] - fakeidp: - build: - context: ../../.. - dockerfile: services/fakeidp/Dockerfile + # Real IdP: same image the compose stack pins (docker-compose.yml), importing + # the generated roster realm (conftest.py runs insight-seed-realm into + # ./keycloak-import before `up`). + keycloak: + image: quay.io/keycloak/keycloak:26.4 + command: ["start-dev", "--import-realm"] environment: - FAKEIDP_ISSUER: "http://fakeidp:8084" - FAKEIDP_BIND: "0.0.0.0:8084" - FAKEIDP_DEFAULT_AUD: "insight-authenticator" - FAKEIDP_DEV_USER_EMAIL: "dev@company.nonpresent" + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + # The advertised issuer is the in-network origin, so the authenticator's + # discovery, token exchange, and the id_token `iss` all agree. The + # host-side pytest rewrites keycloak:8085 to the published port when it + # follows the browser-facing redirects and the login-form action. + KC_HOSTNAME: "http://keycloak:8085" + KC_HTTP_ENABLED: "true" + KC_HTTP_PORT: "8085" + JAVA_OPTS_APPEND: "-Xms256m -Xmx512m" + volumes: + - ./keycloak-import:/opt/keycloak/data/import:ro ports: - # Published so the host-side pytest can follow the OIDC redirect chain - # (rewriting the in-network hostnames to localhost). - - "${FAKEIDP_E2E_PORT:-18084}:8084" + # Published so the host-side pytest can drive the OIDC redirect chain and + # the Keycloak login form. + - "${KEYCLOAK_E2E_PORT:-18084}:8085" authenticator: build: @@ -44,7 +54,7 @@ services: dockerfile: services/authenticator/Dockerfile depends_on: redis: {condition: service_healthy} - fakeidp: {condition: service_started} + keycloak: {condition: service_started} identity-stub: {condition: service_started} ports: # Published so pytest can assert JWKS is served directly by the authenticator. @@ -55,11 +65,15 @@ services: APP__gears__authenticator__config__identity_url: "http://identity-stub:8092" APP__gears__authenticator__config__gateway_issuer: "http://gateway:8080" APP__gears__authenticator__config__redirect_uri: "http://gateway:8080/auth/callback" - APP__gears__authenticator__config__idp__issuer_url: "http://fakeidp:8084" + APP__gears__authenticator__config__idp__issuer_url: "http://keycloak:8085/realms/insight" APP__gears__authenticator__config__idp__client_id: "insight-authenticator" + # The generated realm's confidential-client secret (insight-seed-realm + # default; kc-realm docs call it the dev secret). + APP__gears__authenticator__config__idp__client_secret: "insight-authenticator-dev-secret" # Required since the login-bootstrap person resolve: the identity-stub - # answers any source_type, so the value only has to be non-empty. - APP__gears__authenticator__config__idp__source_type: "fakeidp" + # answers any source_type, so the value only has to be non-empty. The + # external id is the default `sub` claim (the realm user's roster uuid). + APP__gears__authenticator__config__idp__source_type: "keycloak" # Short exchange-cache window so the logout-revocation phase is fast. APP__gears__authenticator__config__authz_cache_max_age_seconds: "3" # Resolve the baked dev `testclient` registry entry's public_key_paths diff --git a/src/backend/services/gateway/tests/downstream-verify/README.md b/src/backend/services/gateway/tests/downstream-verify/README.md index da756f3e8..8896f90af 100644 --- a/src/backend/services/gateway/tests/downstream-verify/README.md +++ b/src/backend/services/gateway/tests/downstream-verify/README.md @@ -11,11 +11,14 @@ The full chain with the **real** downstream services behind the OpenResty gateway: ``` -fakeidp ─▶ authenticator ─▶ gateway ─▶ {analytics, identity-resolution} +keycloak ─▶ authenticator ─▶ gateway ─▶ {analytics, identity-resolution} │ ▲ cookie ─▶ JWT (ES256) │ verifies the JWT via JWKS ``` +- `keycloak` imports the generated roster realm (`insight-seed-realm`, + generated by conftest.py into `./keycloak-import`); the login helper drives + its real HTML login form. - `authenticator` resolves the login user via the **identity-stub** (a test seam so login works without seeding real identity). - `analytics` and `identity-resolution` are the real services; each verifies the gateway @@ -36,14 +39,14 @@ fakeidp ─▶ authenticator ─▶ gateway ─▶ {analytics, identity-resoluti ## Running -Requires `docker`, `openssl`, `pytest`, and — for scenario 4 — `PyJWT` + -`cryptography`: +Requires `docker`, `openssl`, `pytest`, `uv` (realm generation), and — for +scenario 4 — `PyJWT` + `cryptography`: ``` pip install pytest pyjwt cryptography src/backend/services/gateway/tests/downstream-verify/run-e2e.sh ``` -The suite builds the analytics / identity-resolution / authenticator / gateway / fakeidp +The suite builds the analytics / identity-resolution / authenticator / gateway images, so the first run is slow; it is intended for CI and local verification, never a production image. diff --git a/src/backend/services/gateway/tests/downstream-verify/conftest.py b/src/backend/services/gateway/tests/downstream-verify/conftest.py index 2ebc2fd7c..16a4bbce9 100644 --- a/src/backend/services/gateway/tests/downstream-verify/conftest.py +++ b/src/backend/services/gateway/tests/downstream-verify/conftest.py @@ -1,16 +1,21 @@ """Session orchestrator for the downstream-verification e2e. -Owns the compose stack lifecycle (fakeidp + authenticator + gateway + the REAL +Owns the compose stack lifecycle (Keycloak + authenticator + gateway + the REAL analytics and identity services) and exposes a small HTTP client plus a service-token minter. Tests live in test_downstream.py. -pytest runs on the host; the OIDC redirect chain uses in-network hostnames, so -the client rewrites them to the published localhost ports. +The IdP is a real Keycloak importing the generated roster realm +(`insight-seed-realm`, generated here into ./keycloak-import). pytest runs on +the host; the OIDC redirect chain uses in-network hostnames, so the client +rewrites them to the published localhost ports. """ from __future__ import annotations +import http.cookiejar import os +import re +import shutil import subprocess import time import urllib.error @@ -21,6 +26,7 @@ import pytest HERE = Path(__file__).parent +REPO_ROOT = HERE.parents[5] # Exchange-cache window (seconds). Drives the authenticator's # authz_cache_max_age_seconds via the compose `${AUTHZ_CACHE_MAX_AGE:-3}` @@ -32,7 +38,7 @@ SERVICES = [ "redis", "mariadb", - "fakeidp", + "keycloak", "identity-stub", "authenticator", "authn-tls", @@ -44,7 +50,7 @@ ] GW = "http://localhost:18080" -FAKEIDP = "http://localhost:18084" +KEYCLOAK = "http://localhost:18084" AUTHENTICATOR = "http://localhost:18083" AUTH_TOKEN = "http://localhost:18093" # authenticator token listener ANALYTICS_DIRECT = "http://localhost:18081" # bypasses the gateway (R1 proof) @@ -54,7 +60,30 @@ # service_tokens.audience (config/insight.yaml). SERVICE_TOKEN_AUDIENCE = "http://localhost:8093/internal/token" -REWRITES = {"http://gateway:8080": GW, "http://fakeidp:8084": FAKEIDP} +REWRITES = {"http://gateway:8080": GW, "http://keycloak:8085": KEYCLOAK} + +KC_REALM = "insight" +KC_DISCOVERY = f"{KEYCLOAK}/realms/{KC_REALM}/.well-known/openid-configuration" +# The realm generator's dev-lead persona; every realm user's password is the +# generator's baked dev password. +E2E_USER = "dev@company.nonpresent" +E2E_PASSWORD = "insight-dev" +# The realm users' tenant claim; also the tenant of TENANT_DEV in the tests. +TENANT_ID = "00000000-df51-5b42-9538-d2b56b7ee953" + +# Keycloak's login form posts to .../login-actions/authenticate. Matching on +# that rather than "the first " survives extra forms on the page. +_LOGIN_FORM = re.compile(r']+action="([^"]*login-actions/authenticate[^"]*)"', re.IGNORECASE) + + +class _SendSecureOverHttp(http.cookiejar.DefaultCookiePolicy): + """Keycloak marks its auth-session cookies Secure even over plain http, and + the stdlib jar then refuses to send them back over the rig's published http + port — the credential POST would arrive session-less and 400. A browser at + http://localhost has no such problem (secure context).""" + + def return_ok_secure(self, cookie, request): + return True def _compose(*args: str, check: bool = True) -> subprocess.CompletedProcess: @@ -63,9 +92,10 @@ def _compose(*args: str, check: bool = True) -> subprocess.CompletedProcess: class Client: """Minimal HTTP client: no auto-redirects, case-insensitive headers, an OIDC - login helper, and a form POST for the service-token exchange.""" + login helper that drives Keycloak's HTML login form, and a form POST for + the service-token exchange.""" - def request(self, url, headers=None, method="GET", data=None): + def request(self, url, headers=None, method="GET", data=None, jar=None): body = None hdrs = dict(headers or {}) if data is not None: @@ -77,7 +107,10 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *a, **k): return None - opener = urllib.request.build_opener(_NoRedirect) + handlers: list = [_NoRedirect] + if jar is not None: + handlers.append(urllib.request.HTTPCookieProcessor(jar)) + opener = urllib.request.build_opener(*handlers) try: resp = opener.open(req, timeout=20) return resp.status, self._lower(resp.headers), resp.read() @@ -97,15 +130,26 @@ def _rewrite(url): def login(self, user=None): """Drive the OIDC code flow through the gateway; return the __Host-sid value. - `user` optionally picks a fakeidp test user (by email) — the fake - `/authorize` honours a `user=` query param; omit for the default dev user. + Keycloak serves a real HTML login form, so the middle of the chain is: + GET the authorize URL (collecting the IdP's auth-session cookies), parse + the form action, POST the credentials, then deliver the code redirect to + the gateway callback. `user` optionally picks another realm user (by + email); omit for the default dev-lead persona. """ _, h, _ = self.request(f"{GW}/auth/login?return_to=/") - authorize = self._rewrite(h["location"]) # fakeidp /authorize - if user is not None: - sep = "&" if "?" in authorize else "?" - authorize = f"{authorize}{sep}user={urllib.parse.quote(user)}" - _, h, _ = self.request(authorize) + + jar = http.cookiejar.CookieJar(policy=_SendSecureOverHttp()) + status, _, body = self.request(self._rewrite(h["location"]), jar=jar) + assert status == 200, f"authorize expected the login form, got {status}" + match = _LOGIN_FORM.search(body.decode()) + assert match, "no Keycloak login form in the authorize response" + action = self._rewrite(match.group(1).replace("&", "&")) + + status, h, _ = self.request( + action, method="POST", data={"username": user or E2E_USER, "password": E2E_PASSWORD}, jar=jar + ) + assert status == 302, f"credential POST expected 302, got {status}" + status, h, _ = self.request(self._rewrite(h["location"])) # gateway /auth/callback assert status == 302, f"callback expected 302, got {status}" for part in h.get("set-cookie", "").split(";"): @@ -129,6 +173,33 @@ def _wait_http(url, want, timeout_s=120): raise TimeoutError(f"not ready: {url} (last={last})") +def _generate_realm(import_dir: Path) -> None: + """Generate the Keycloak import realm with `insight-seed-realm` (uv resolves + and installs the seed package on first use). The compose default redirect + URIs would deregister the gateway callback (--authenticator-redirect + REPLACES, not appends), so it is passed explicitly.""" + seed = REPO_ROOT / "src" / "ingestion" / "tools" / "seed" + import_dir.mkdir(exist_ok=True) + subprocess.run( + [ + "uv", + "run", + "--project", + str(seed), + "insight-seed-realm", + "--dev-email", + E2E_USER, + "--authenticator-redirect", + "http://gateway:8080/auth/callback", + "--out", + str(import_dir / "realm-insight.json"), + ], + check=True, + capture_output=True, + env={**os.environ, "TENANT_DEFAULT_ID": TENANT_ID}, + ) + + def _genpkey_ec(path: Path) -> None: subprocess.run( [ @@ -219,8 +290,14 @@ def stack(): capture_output=True, ) (keys / "testclient.pub.pem").chmod(0o644) + kc_import = HERE / "keycloak-import" + _generate_realm(kc_import) try: _compose("up", "-d", "--build", *SERVICES) + # Keycloak start + realm import runs tens of seconds; the realm + # discovery document answers only once its import committed. The + # authenticator discovers per-op, so it needs no restart after this. + _wait_http(KC_DISCOVERY, want={200}, timeout_s=240) _wait_http(f"{GW}/healthz", want={200}) _wait_http(f"{GW}/auth/login", want={302}) # Both downstream services up: /health is public on each host, and a @@ -237,6 +314,7 @@ def stack(): for leftover in ("server.key", "server.pem", "ca.pem", "openssl.cnf"): (certs / leftover).unlink(missing_ok=True) certs.rmdir() + shutil.rmtree(kc_import, ignore_errors=True) @pytest.fixture diff --git a/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml b/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml index 178bf40f4..b487aec1e 100644 --- a/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml +++ b/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml @@ -1,7 +1,7 @@ # Downstream-verification e2e (dev/CI ONLY) -- NGINX_BFF §6 R1, step-07 §D. # # The full chain with the REAL downstream services behind the OpenResty gateway: -# fakeidp -> authenticator -> gateway -> {analytics, identity-resolution} +# keycloak -> authenticator -> gateway -> {analytics, identity-resolution} # Both analytics and identity-resolution (Rust) verify the gateway JWT themselves # (fail-closed, no disable knob). The authenticator resolves the login user via # the identity-stub (a test seam so login works without seeding real identity); @@ -38,17 +38,29 @@ services: timeout: 5s retries: 40 - fakeidp: - build: - context: ../../../.. - dockerfile: services/fakeidp/Dockerfile + # Real IdP: same image the compose stack pins (docker-compose.yml), importing + # the generated roster realm (conftest.py runs insight-seed-realm into + # ./keycloak-import before `up`). + keycloak: + image: quay.io/keycloak/keycloak:26.4 + command: ["start-dev", "--import-realm"] environment: - FAKEIDP_ISSUER: "http://fakeidp:8084" - FAKEIDP_BIND: "0.0.0.0:8084" - FAKEIDP_DEFAULT_AUD: "insight-authenticator" - FAKEIDP_DEV_USER_EMAIL: "dev@company.nonpresent" + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + # The advertised issuer is the in-network origin, so the authenticator's + # discovery, token exchange, and the id_token `iss` all agree. The + # host-side pytest rewrites keycloak:8085 to the published port when it + # follows the browser-facing redirects and the login-form action. + KC_HOSTNAME: "http://keycloak:8085" + KC_HTTP_ENABLED: "true" + KC_HTTP_PORT: "8085" + JAVA_OPTS_APPEND: "-Xms256m -Xmx512m" + volumes: + - ./keycloak-import:/opt/keycloak/data/import:ro ports: - - "${FAKEIDP_E2E_PORT:-18084}:8084" + # Published so the host-side pytest can drive the OIDC redirect chain and + # the Keycloak login form. + - "${KEYCLOAK_E2E_PORT:-18084}:8085" # Canned person resolver for the authenticator's login step (email -> person). # Keeps login working without seeding real identity; the real @@ -65,7 +77,7 @@ services: dockerfile: services/authenticator/Dockerfile depends_on: redis: {condition: service_healthy} - fakeidp: {condition: service_started} + keycloak: {condition: service_started} identity-stub: {condition: service_started} ports: - "${AUTHENTICATOR_E2E_PORT:-18083}:8083" @@ -79,8 +91,13 @@ services: # https-only). The browser still reaches the gateway plainly at :8080. APP__gears__authenticator__config__gateway_issuer: "https://authn-tls:8443" APP__gears__authenticator__config__redirect_uri: "http://gateway:8080/auth/callback" - APP__gears__authenticator__config__idp__issuer_url: "http://fakeidp:8084" + APP__gears__authenticator__config__idp__issuer_url: "http://keycloak:8085/realms/insight" APP__gears__authenticator__config__idp__client_id: "insight-authenticator" + # The generated realm's confidential-client secret (insight-seed-realm + # default). The external id is the default `sub` claim (the realm user's + # roster uuid); the identity-stub answers any source_type. + APP__gears__authenticator__config__idp__client_secret: "insight-authenticator-dev-secret" + APP__gears__authenticator__config__idp__source_type: "keycloak" # Short exchange-cache window so revocation-adjacent assertions are fast. # Sourced from conftest.py's AUTHZ_CACHE_MAX_AGE (exported into the env). APP__gears__authenticator__config__authz_cache_max_age_seconds: "${AUTHZ_CACHE_MAX_AGE:-3}" @@ -138,11 +155,18 @@ services: depends_on: mariadb: {condition: service_healthy} environment: - APP__gears__identity-resolution__config__database_url: "mysql://insight:insight-local@mariadb:3306/identity" + # UNDERSCORE, not hyphen, in the gear name: the shared entrypoint runs + # under dash, which drops env vars whose names are not valid shell + # identifiers; gears-rust normalises `_` to `-` in the key path. + APP__gears__identity_resolution__config__database_url: "mysql://insight:insight-local@mariadb:3306/identity" volumes: - ./identity-resolution.e2e.yaml:/app/config/insight.yaml:ro - ./certs:/certs:ro - command: ["/app/identity-resolution", "-c", "/app/config/insight.yaml", "migrate"] + # The image entrypoint is the shared auto-reload wrapper + # (docker-entrypoint.sh -- ); with + # ENABLE_AUTO_RELOAD unset it just execs the command, so the one-shot + # migrate still exits when done. + command: ["/app/identity-resolution", "--", "/app/identity-resolution", "-c", "/app/config/insight.yaml", "migrate"] restart: "no" identity-resolution: @@ -155,7 +179,8 @@ services: authn-tls: {condition: service_started} identity-resolution-migrate: {condition: service_completed_successfully} environment: - APP__gears__identity-resolution__config__database_url: "mysql://insight:insight-local@mariadb:3306/identity" + # Shell-safe underscore spelling (see identity-resolution-migrate above). + APP__gears__identity_resolution__config__database_url: "mysql://insight:insight-local@mariadb:3306/identity" # The oidc-authn-plugin verification config is nested/list-shaped, so it # comes from a bind-mounted host config (concrete issuer + CA), not env. volumes: diff --git a/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh b/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh index 79386aca0..af8c5ed41 100755 --- a/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh +++ b/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # Downstream-verification e2e (NGINX_BFF §6 R1 / §D). # -# Brings up the full chain — fakeidp + authenticator + gateway + the REAL +# Brings up the full chain — Keycloak + authenticator + gateway + the REAL # analytics and identity-resolution services + MariaDB/Redis — and asserts # the five downstream-verification scenarios. Stack lifecycle + assertions live # in conftest.py + test_downstream.py. # -# Requires: docker, openssl, pytest, and (for the service-token scenario) PyJWT -# (`pip install pytest pyjwt cryptography`). +# Requires: docker, openssl, pytest, uv (conftest generates the Keycloak import +# realm with `uv run ... insight-seed-realm`), and (for the service-token +# scenario) PyJWT (`pip install pytest pyjwt cryptography`). set -euo pipefail cd "$(dirname "$0")" diff --git a/src/backend/services/gateway/tests/run-e2e.sh b/src/backend/services/gateway/tests/run-e2e.sh index 6b0936732..d6e8d0350 100755 --- a/src/backend/services/gateway/tests/run-e2e.sh +++ b/src/backend/services/gateway/tests/run-e2e.sh @@ -5,7 +5,8 @@ # pytest suite (conftest.py + test_gateway.py). This just runs pytest from the # tests directory; pass extra pytest args through (e.g. `-k revocation -v`). # -# Requires: docker, openssl, and pytest (`pip install pytest`). +# Requires: docker, openssl, pytest (`pip install pytest`), and uv (conftest +# generates the Keycloak import realm with `uv run ... insight-seed-realm`). set -euo pipefail cd "$(dirname "$0")" diff --git a/src/backend/services/identity-resolution/Dockerfile b/src/backend/services/identity-resolution/Dockerfile index 819af5fc2..c54a6b3b0 100644 --- a/src/backend/services/identity-resolution/Dockerfile +++ b/src/backend/services/identity-resolution/Dockerfile @@ -23,7 +23,6 @@ COPY libs/authenticator-sdk/Cargo.toml ./libs/authenticator-sdk/Cargo.toml COPY services/analytics/Cargo.toml ./services/analytics/Cargo.toml COPY services/authenticator/Cargo.toml ./services/authenticator/Cargo.toml COPY services/identity-resolution/Cargo.toml ./services/identity-resolution/Cargo.toml -COPY services/fakeidp/Cargo.toml ./services/fakeidp/Cargo.toml COPY tools/routegen/Cargo.toml ./tools/routegen/Cargo.toml RUN mkdir -p libs/insight-clickhouse/src && echo "" > libs/insight-clickhouse/src/lib.rs && \ @@ -31,7 +30,6 @@ RUN mkdir -p libs/insight-clickhouse/src && echo "" > libs/insight-clickhouse/sr mkdir -p services/analytics/src && echo "fn main() {}" > services/analytics/src/main.rs && \ mkdir -p services/authenticator/src && echo "fn main() {}" > services/authenticator/src/main.rs && \ mkdir -p services/identity-resolution/src && echo "fn main() {}" > services/identity-resolution/src/main.rs && \ - mkdir -p services/fakeidp/src && echo "fn main() {}" > services/fakeidp/src/main.rs && \ mkdir -p tools/routegen/src && echo "fn main() {}" > tools/routegen/src/main.rs RUN cargo build --release --bin identity-resolution 2>/dev/null || true diff --git a/src/ingestion/tests/e2e/lib/api_coverage.py b/src/ingestion/tests/e2e/lib/api_coverage.py index 8a9c46995..82980a79f 100755 --- a/src/ingestion/tests/e2e/lib/api_coverage.py +++ b/src/ingestion/tests/e2e/lib/api_coverage.py @@ -153,8 +153,9 @@ # limiter's 429s are real but undeclared — extra observed codes are ignored. AUTHENTICATOR_UNIVERSAL_BOILERPLATE = frozenset() # back-channel-logout's 200 is answered to the IdP's server-side POST (proven -# via fakeidp's rp_status assertion in e2e_backchannel), never to the test -# client — the client can only observe the 400 rejection. +# in e2e_backchannel: Keycloak fires the signed logout_token and the user's +# sessions die), never to the test client — the client can only observe the +# 400 rejection. AUTHENTICATOR_BLOCKED: dict[str, frozenset[int]] = {"POST /auth/oidc/back-channel-logout": frozenset({200})} AUTHENTICATOR_REQUIRED_EXTRA: dict[str, frozenset[int]] = {} diff --git a/src/ingestion/tools/seed/PROFILE.md b/src/ingestion/tools/seed/PROFILE.md index b624861a2..78eb374f7 100644 --- a/src/ingestion/tools/seed/PROFILE.md +++ b/src/ingestion/tools/seed/PROFILE.md @@ -17,7 +17,7 @@ builder that writes `manifest.json`, so the two cannot disagree. | realm | `insight` | | anchor_date | `2026-06-30` | | data_window | `2026-05-02..2026-06-30` | -| seed_revision | `8085d4bfcbe92ae4` | +| seed_revision | `0ff9aa1efdc0ef1c` | | manifest_version | 1 | `anchor_date` is the last day carrying seeded activity. It is resolved @@ -118,7 +118,7 @@ criteria an entry must meet before it is added. | capability | value | |---|---| -| `idp` | fakeidp | +| `idp` | keycloak | | `ingestion` | no | | `service_principals` | yes | diff --git a/src/ingestion/tools/seed/insight_seed/identity.py b/src/ingestion/tools/seed/insight_seed/identity.py index c0078fb13..3cc775840 100644 --- a/src/ingestion/tools/seed/insight_seed/identity.py +++ b/src/ingestion/tools/seed/insight_seed/identity.py @@ -186,11 +186,10 @@ def seed_login_ids( looks up. Without them a fresh dev/demo/CI stack can authenticate against the IdP but never resolves to a person (403 at callback). - WHICH persona(s) get a row depends on the active IdP fixture (see - `profiles.get_login_id_pairs`): fakeidp only defines the dev lead; a - Keycloak realm seeds the WHOLE roster, so every one of those 25 personas - must get their own row here too, or logging in as anyone but the dev lead - 403s despite Keycloak having authenticated them correctly. + Every roster persona gets a row (see `profiles.get_login_id_pairs`): the + Keycloak realm seeds the WHOLE roster, so every persona must get their + own row here too, or logging in as anyone but the dev lead 403s despite + Keycloak having authenticated them correctly. Idempotent via an explicit existence check per pair, NOT `INSERT IGNORE`: since migration 004 (`004_persons_relax_constraints.sql`), `persons`' diff --git a/src/ingestion/tools/seed/insight_seed/manifest.py b/src/ingestion/tools/seed/insight_seed/manifest.py index 846812be9..30717b98c 100644 --- a/src/ingestion/tools/seed/insight_seed/manifest.py +++ b/src/ingestion/tools/seed/insight_seed/manifest.py @@ -78,7 +78,6 @@ else "00000000-df51-5b42-9538-d2b56b7ee953", "SEED_ANCHOR_DATE": "2026-06-30", "SEED_DAYS": "60", - "AUTH_MODE": "", "AUTHENTICATOR_OIDC_ISSUER": "", # The canonical stand carries the cross-tenant refusal fixture, so the # committed PROFILE.md describes a compose stand — the one the suite reads. @@ -269,11 +268,11 @@ def build_manifest( personas = [_persona(p) for p in roster] - auth_mode = (env.get("AUTH_MODE") or "").strip().lower() issuer = (env.get("AUTHENTICATOR_OIDC_ISSUER") or "").strip() - # Sourced, never guessed: when the stand did not tell us, say fakeidp and - # leave the issuer empty rather than inventing a Keycloak URL. - idp = auth_mode if auth_mode in {"keycloak", "fakeidp"} else "fakeidp" + # Mirrors `profiles.get_idp_source_type`: consumers use this value as the + # identity source_type of the seeded login rows, so it must be the one + # they were actually written under. + idp = (env.get("IDP_SOURCE_TYPE") or "").strip() or "keycloak" return { "manifest_version": MANIFEST_VERSION, diff --git a/src/ingestion/tools/seed/insight_seed/profiles.py b/src/ingestion/tools/seed/insight_seed/profiles.py index 66defff04..582074bd4 100644 --- a/src/ingestion/tools/seed/insight_seed/profiles.py +++ b/src/ingestion/tools/seed/insight_seed/profiles.py @@ -384,36 +384,19 @@ def get_dev_user_email() -> str: return val -# fakeidp's users.yaml pins its first user's `sub` to "fakeidp|dev" (stable -# regardless of FAKEIDP_DEV_USER_EMAIL overriding the email) — that's the only -# fakeidp identity a fresh dev/demo/CI stack ever logs in as. -_FAKEIDP_DEV_LEAD_EXTERNAL_ID = "fakeidp|dev" - - def get_login_id_pairs(roster: list[Person]) -> list[tuple[str, str]]: """Resolve the `(person_uuid, external_id)` pairs to seed as - `value_type='id'` login-bootstrap observations, for the ACTIVE login IdP. - - dev-compose.sh's `--auth` flag (`AUTH_MODE`, forwarded into this - container's environment) selects which IdP fixture applies, and the two - are NOT symmetric in how many personas can actually log in: - - keycloak: `keycloak_realm` sets EVERY realm user's Keycloak `id` to that - person's OWN roster UUID (`"id": person.uuid`), and Keycloak issues - `sub` equal to the user's internal id verbatim — so every seeded - persona's external id IS their own roster uuid, and the whole roster - can log in (matching the realm, which seeds all of them). - - fakeidp (default): its users.yaml only pins a handful of FIXED test - identities unrelated to the demo roster (dev/alice/bob/carol — see - services/fakeidp/users.yaml), of which only the first ("fakeidp|dev") - corresponds to a roster member (the dev lead, anchored via - DEV_USER_EMAIL). Only that one persona can log in. - Getting this wrong means the login-bootstrap 403s: the seeded - `value_type='id'` row would carry a value the id_token never presents. + `value_type='id'` login-bootstrap observations. + + `keycloak_realm` sets EVERY realm user's Keycloak `id` to that person's + OWN roster UUID (`"id": person.uuid`), and Keycloak issues `sub` equal to + the user's internal id verbatim — so every seeded persona's external id + IS their own roster uuid, and the whole roster can log in (matching the + realm, which seeds all of them). Getting this wrong means the + login-bootstrap 403s: the seeded `value_type='id'` row would carry a + value the id_token never presents. """ - mode = os.environ.get("AUTH_MODE", "fakeidp").strip().lower() - if mode == "keycloak": - return [(p.uuid, p.uuid) for p in roster] - return [(DEV_LEAD_UUID, _FAKEIDP_DEV_LEAD_EXTERNAL_ID)] + return [(p.uuid, p.uuid) for p in roster] def get_idp_source_type() -> str: @@ -421,4 +404,4 @@ def get_idp_source_type() -> str: IDP_SOURCE_TYPE — MUST match the authenticator's `idp.source_type`, or the dev-lead's seeded value_type='id' row won't be the one the login-bootstrap lookup finds.""" - return os.environ.get("IDP_SOURCE_TYPE", "fakeidp").strip() or "fakeidp" + return os.environ.get("IDP_SOURCE_TYPE", "keycloak").strip() or "keycloak" diff --git a/src/ingestion/tools/seed/seed-job.yaml.tpl b/src/ingestion/tools/seed/seed-job.yaml.tpl index ed972daa9..aa81b33b3 100644 --- a/src/ingestion/tools/seed/seed-job.yaml.tpl +++ b/src/ingestion/tools/seed/seed-job.yaml.tpl @@ -18,8 +18,7 @@ # SEED_CLICKHOUSE_HOST/_HTTP_PORT/_USER/_DATABASE # SEED_TENANT_ID tenant every seeded row is scoped to # SEED_DEV_USER_EMAIL persona the dev-lead login resolves to -# SEED_AUTH_MODE keycloak | fakeidp — which personas get login rows -# SEED_IDP_SOURCE_TYPE identity source_type those rows are written under +# SEED_IDP_SOURCE_TYPE identity source_type the login rows are written under # SEED_CROSS_TENANT 0 | 1 — write the second-tenant refusal fixture # SEED_FORCE 0 | 1 — seed a tenant holding foreign person rows # SEED_WINDOW_DAYS activity-window length, empty for the seeder's own @@ -109,8 +108,6 @@ spec: value: "${SEED_TENANT_ID}" - name: DEV_USER_EMAIL value: "${SEED_DEV_USER_EMAIL}" - - name: AUTH_MODE - value: "${SEED_AUTH_MODE}" - name: IDP_SOURCE_TYPE value: "${SEED_IDP_SOURCE_TYPE}" # Off on a cluster stand: a second tenant makes diff --git a/src/ingestion/tools/seed/seed-stand.sh b/src/ingestion/tools/seed/seed-stand.sh index 8ba37edac..db8dc6795 100755 --- a/src/ingestion/tools/seed/seed-stand.sh +++ b/src/ingestion/tools/seed/seed-stand.sh @@ -39,7 +39,6 @@ ANALYTICS_DB="" IDENTITY_DB="" DB_SECRET="" PULL_SECRETS="" -AUTH_MODE="" IDP_SOURCE_TYPE="" WINDOW_DAYS="" ANCHOR_DATE="" @@ -70,8 +69,7 @@ Discovered from the stand (pass a flag only to override): --identity-db database holding persons --db-secret Secret holding mariadb-password + clickhouse-password --pull-secret image-pull Secret (default: the release's own) - --auth-mode keycloak | fakeidp — which personas get login rows - --idp-source-type identity source_type those rows are written under + --idp-source-type identity source_type the login rows are written under Seed options: --step identity | silver | analytics | all [default: all] @@ -131,7 +129,6 @@ while [[ $# -gt 0 ]]; do --identity-db) IDENTITY_DB="${2:?--identity-db needs a value}"; shift 2 ;; --db-secret) DB_SECRET="${2:?--db-secret needs a value}"; shift 2 ;; --pull-secret) PULL_SECRETS="[{\"name\":\"${2:?--pull-secret needs a value}\"}]"; shift 2 ;; - --auth-mode) AUTH_MODE="${2:?--auth-mode needs a value}"; shift 2 ;; --idp-source-type) IDP_SOURCE_TYPE="${2:?--idp-source-type needs a value}"; shift 2 ;; --step) STEP="${2:?--step needs a value}"; shift 2 ;; --days) WINDOW_DAYS="${2:?--days needs a value}"; shift 2 ;; @@ -275,17 +272,6 @@ if [[ -z "$IDP_SOURCE_TYPE" ]]; then 'APP__gears__authenticator__config__idp__source_type')" fi -# Which personas get a login-bootstrap row follows from the IdP: a Keycloak -# realm can authenticate the whole roster, the fakeidp fixture only the dev -# lead. Rows for people a realm does not hold are inert observations. -if [[ -z "$AUTH_MODE" ]]; then - if [[ "$IDP_SOURCE_TYPE" == "fakeidp" ]]; then - AUTH_MODE="fakeidp" - elif [[ -n "$IDP_SOURCE_TYPE" ]]; then - AUTH_MODE="keycloak" - fi -fi - if [[ -z "$IMAGE" || -z "$PULL_SECRETS" ]]; then need helm need jq @@ -361,10 +347,6 @@ check "$IDP_SOURCE_TYPE" \ resolves logins by email instead, and any stable label (e.g. 'keycloak') is then correct as long as it matches what the authenticator will use later" \ "--idp-source-type" -check "$AUTH_MODE" \ - "which personas get a login-bootstrap row ('keycloak' = the whole roster, - 'fakeidp' = the dev lead only). Derived from the source_type above" \ - "--auth-mode" if [[ "$missing_count" -gt 0 ]]; then echo "ERROR: could not resolve $missing_count value(s) from namespace $NAMESPACE:" >&2 @@ -375,7 +357,7 @@ fi echo "==> tenant: $TENANT" echo "==> databases: identity=$IDENTITY_DB analytics=$ANALYTICS_DB clickhouse=$CLICKHOUSE_DATABASE" echo "==> image: $IMAGE" -echo "==> idp: auth_mode=$AUTH_MODE source_type=$IDP_SOURCE_TYPE dev_user=$DEV_EMAIL" +echo "==> idp: source_type=$IDP_SOURCE_TYPE dev_user=$DEV_EMAIL" # ── Render ────────────────────────────────────────────────────────────────── # A name per run: a Job's pod spec is immutable, so reusing one name would make @@ -399,7 +381,6 @@ export SEED_CLICKHOUSE_USER="$CLICKHOUSE_USER" export SEED_CLICKHOUSE_DATABASE="$CLICKHOUSE_DATABASE" export SEED_TENANT_ID="$TENANT" export SEED_DEV_USER_EMAIL="$DEV_EMAIL" -export SEED_AUTH_MODE="$AUTH_MODE" export SEED_IDP_SOURCE_TYPE="$IDP_SOURCE_TYPE" export SEED_CROSS_TENANT="$CROSS_TENANT" export SEED_FORCE="$FORCE" @@ -418,7 +399,7 @@ manifest="$(envsubst ' ${SEED_IDENTITY_DB} ${SEED_ANALYTICS_DB} ${SEED_CLICKHOUSE_HOST} ${SEED_CLICKHOUSE_HTTP_PORT} ${SEED_CLICKHOUSE_USER} ${SEED_CLICKHOUSE_DATABASE} - ${SEED_TENANT_ID} ${SEED_DEV_USER_EMAIL} ${SEED_AUTH_MODE} ${SEED_IDP_SOURCE_TYPE} + ${SEED_TENANT_ID} ${SEED_DEV_USER_EMAIL} ${SEED_IDP_SOURCE_TYPE} ${SEED_CROSS_TENANT} ${SEED_FORCE} ${SEED_WINDOW_DAYS} ${SEED_ANCHOR_DATE} ${SEED_PULL_SECRETS} ' < "$JOB_TEMPLATE")" diff --git a/src/ingestion/tools/seed/tests/test_identity.py b/src/ingestion/tools/seed/tests/test_identity.py index 3126e7819..99da94eb3 100644 --- a/src/ingestion/tools/seed/tests/test_identity.py +++ b/src/ingestion/tools/seed/tests/test_identity.py @@ -7,10 +7,9 @@ `created_at` in `persons`' unique key, so `INSERT IGNORE` alone no longer dedupes a re-run (each insert gets a fresh `created_at`, so the unique key never collides). Every writer must check for an existing row explicitly. -2. Roster scope: fakeidp only defines a fixed dev-lead identity, but a - Keycloak realm seeds the WHOLE roster (`keycloak_realm` pins every realm - user's id to their own roster uuid) — `seed_login_ids` must seed a row per - roster member under `AUTH_MODE=keycloak`, not just the dev lead. +2. Roster scope: the Keycloak realm seeds the WHOLE roster (`keycloak_realm` + pins every realm user's id to their own roster uuid) — `seed_login_ids` + must seed a row per roster member, not just the dev lead. Run against the installed package (see the README's develop section): @@ -87,38 +86,21 @@ def fetchone(self) -> tuple[int] | None: class SeedLoginIdsTests(unittest.TestCase): - """Both variables are SET, not defaulted: `profiles` reads them at call time, + """IDP_SOURCE_TYPE is SET, not defaulted: `profiles` reads it at call time, so a value left over from the developer's shell would otherwise decide which - personas these tests expect.""" - - _ENV = ("AUTH_MODE", "IDP_SOURCE_TYPE") + source_type these tests expect.""" def setUp(self) -> None: - self._previous = {name: os.environ.get(name) for name in self._ENV} - os.environ["IDP_SOURCE_TYPE"] = "fakeidp" + self._previous = os.environ.get("IDP_SOURCE_TYPE") + os.environ["IDP_SOURCE_TYPE"] = "keycloak" def tearDown(self) -> None: - for name, value in self._previous.items(): - if value is None: - os.environ.pop(name, None) - else: - os.environ[name] = value - - def test_second_run_does_not_insert_a_duplicate(self) -> None: - os.environ["AUTH_MODE"] = "fakeidp" - cur = _FakeCursor() - roster = _roster() - - # The fake cursor implements only what these writers call. - first_run_count = identity.seed_login_ids(cur, _TENANT, roster) # type: ignore[arg-type] - second_run_count = identity.seed_login_ids(cur, _TENANT, roster) # type: ignore[arg-type] - - self.assertEqual(first_run_count, 1, "fakeidp seeds only the dev lead") - self.assertEqual(second_run_count, 0, "re-run must be a no-op, not a duplicate insert") - self.assertEqual(cur.insert_count, 1, "only one INSERT should ever have executed") + if self._previous is None: + os.environ.pop("IDP_SOURCE_TYPE", None) + else: + os.environ["IDP_SOURCE_TYPE"] = self._previous - def test_keycloak_seeds_the_whole_roster(self) -> None: - os.environ["AUTH_MODE"] = "keycloak" + def test_whole_roster_is_seeded_once_across_two_runs(self) -> None: cur = _FakeCursor() roster = _roster() @@ -129,7 +111,7 @@ def test_keycloak_seeds_the_whole_roster(self) -> None: self.assertEqual( first_run_count, len(roster), - "keycloak seeds every roster persona (keycloak_realm registers all of them)", + "every roster persona gets a login row (keycloak_realm registers all of them)", ) self.assertEqual(second_run_count, 0, "re-run must be a no-op for every pair") self.assertEqual(cur.insert_count, len(roster)) @@ -184,7 +166,7 @@ class SeedPersonsIdempotencyTests(unittest.TestCase): def setUp(self) -> None: self._previous = os.environ.get("IDP_SOURCE_TYPE") - os.environ["IDP_SOURCE_TYPE"] = "fakeidp" + os.environ["IDP_SOURCE_TYPE"] = "keycloak" def tearDown(self) -> None: if self._previous is None: diff --git a/tests/lib/insight_stand/manifest.py b/tests/lib/insight_stand/manifest.py index e24f9fc7e..12286189d 100644 --- a/tests/lib/insight_stand/manifest.py +++ b/tests/lib/insight_stand/manifest.py @@ -173,9 +173,9 @@ def has(self, name: str) -> bool: capability marker's table would otherwise skip every test carrying it, with a reason that reads perfectly plausibly. - `idp` is deliberately not answerable here — it is a VALUE - (`keycloak` | `fakeidp`), not a yes/no, so comparing it is the caller's - job. + `idp` is deliberately not answerable here — it is a VALUE (the + identity source_type the login rows were seeded under, e.g. + `keycloak`), not a yes/no, so comparing it is the caller's job. """ if name not in BOOLEAN_CAPABILITIES: known = ", ".join(sorted(BOOLEAN_CAPABILITIES)) diff --git a/tests/lib/insight_stand/session.py b/tests/lib/insight_stand/session.py index f5c68ac25..e8f1ff784 100644 --- a/tests/lib/insight_stand/session.py +++ b/tests/lib/insight_stand/session.py @@ -151,7 +151,7 @@ def _start(self, client: httpx.Client) -> str: if response.status_code not in (301, 302, 303, 307, 308) or not location: raise LoginNotCompletedError( f"GET {self.login_path} did not redirect to the IdP " - f"(status {response.status_code}); is AUTH_MODE=keycloak on this stand?", + f"(status {response.status_code}); is Keycloak up on this stand?", stopped_at=str(response.url), ) return str(location) diff --git a/tests/stand/api/identity/test_internal.py b/tests/stand/api/identity/test_internal.py index af2e62e88..465dd62d1 100644 --- a/tests/stand/api/identity/test_internal.py +++ b/tests/stand/api/identity/test_internal.py @@ -49,30 +49,21 @@ from ..schemas import IdentityValue -# The dev lead's fixed external id under fakeidp — mirrors -# `src/ingestion/tools/seed/profiles.py::_FAKEIDP_DEV_LEAD_EXTERNAL_ID`. fakeidp's -# users.yaml pins its first user's `sub` to this value regardless of which -# email the dev lead persona was seeded under, so it cannot be derived from -# the manifest fixture and has to be duplicated here, same as the seed does. -_FAKEIDP_DEV_LEAD_EXTERNAL_ID = "fakeidp|dev" - def _dev_lead_login_id(stand_manifest: Manifest) -> tuple[str, str]: """`(source_type, external_id)` the dev lead's login-bootstrap row was - seeded under, for whichever IdP this stand runs. + seeded under. Mirrors `src/ingestion/tools/seed/profiles.py::get_login_id_pairs` / - `get_idp_source_type`: on `keycloak` every persona's external id is their - own roster uuid; on `fakeidp` only the dev lead can log in at all, under - the fixed id above. `capabilities.idp` (`"keycloak"` | `"fakeidp"`) is - usable directly as `source_type` because compose always seeds - `AUTHENTICATOR_IDP_SOURCE_TYPE` from the same `AUTH_MODE` (see - `dev-compose.sh`, `docker-compose.yml`). + `get_idp_source_type`: every persona's external id is their own roster + uuid (`keycloak_realm` pins each realm user's Keycloak id to it, and + Keycloak issues `sub` verbatim). `capabilities.idp` is the identity + source_type those rows were written under, so it is usable directly as + `source_type` here. """ source_type = stand_manifest.capabilities.idp person = stand_manifest.fixture("dev_lead") - external_id = person.uuid if source_type == "keycloak" else _FAKEIDP_DEV_LEAD_EXTERNAL_ID - return source_type, external_id + return source_type, person.uuid @pytest.mark.requires_service_principal From 4e747476823430c49bcf5e6ba6908b679ec86bd6 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Fri, 7 Aug 2026 13:02:18 +0800 Subject: [PATCH 2/2] fix(review): fail-closed realm gate, wider paths filter, dead AUTH_MODE plumbing, comment trims CodeRabbit follow-ups on #2315: the functional-k3s realm step fails when keycloak.deploy != true instead of silently skipping; gateway.yml triggers on the whole insight_seed package (the realm generator imports config + profiles); the seed container's AUTH_MODE env and dev-compose export are dropped (nothing reads AUTH_MODE anymore); misleading Keycloak-liveness error hint and stale back-channel comment fixed; wordier new comments trimmed. Co-Authored-By: Claude Fable 5 Signed-off-by: Anton Zelenov --- .github/workflows/functional-k3s.yml | 9 +++++---- .github/workflows/gateway.yml | 4 ++-- .../environments/functional-ci/values.yaml | 18 ++++++------------ dev-compose.sh | 8 +------- docker-compose.yml | 4 ---- .../services/gateway/tests/conftest.py | 19 ++++++++----------- .../gateway/tests/docker-compose.e2e.yml | 6 ++---- .../gateway/tests/downstream-verify/README.md | 2 +- .../tests/downstream-verify/conftest.py | 14 ++++++-------- .../downstream-verify/docker-compose.e2e.yml | 6 ++---- src/ingestion/tests/e2e/lib/api_coverage.py | 6 ++---- tests/lib/insight_stand/session.py | 2 +- 12 files changed, 36 insertions(+), 62 deletions(-) diff --git a/.github/workflows/functional-k3s.yml b/.github/workflows/functional-k3s.yml index 444eef802..508d6d9cb 100644 --- a/.github/workflows/functional-k3s.yml +++ b/.github/workflows/functional-k3s.yml @@ -187,13 +187,14 @@ jobs: make -C deploy/gitops system ENV="${GITOPS_ENV}" KUBE_CTX="${KUBE_CONTEXT}" make -C deploy/gitops system-status ENV="${GITOPS_ENV}" KUBE_CTX="${KUBE_CONTEXT}" - # Generates the Keycloak roster realm JSON into the env realms dir and - # applies the insight-keycloak-config admin Secret; deploy-app's chained - # keycloak-broker-realms target packs the realm into the ConfigMap the - # keycloak-config-cli hook Job applies. + # deploy-app's chained keycloak-broker-realms target packs the generated + # realm into the ConfigMap the keycloak-config-cli hook Job applies. - name: Generate Keycloak realm run: | set -euo pipefail + # keycloak-realm no-ops (exit 0) when keycloak.deploy != true; this + # smoke requires the realm, so fail closed on a misconfigured env. + test "$(yq -r '.keycloak.deploy // false' "deploy/gitops/environments/${GITOPS_ENV}/values.yaml")" = "true" make -C deploy/gitops keycloak-realm ENV="${GITOPS_ENV}" KUBE_CTX="${KUBE_CONTEXT}" - name: Deploy Insight via gitops Makefile diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index dda3241a0..e8e1d6f14 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -13,8 +13,8 @@ on: paths: - "src/backend/tools/routegen/**" - "src/backend/services/gateway/**" - # The e2e Keycloak imports the generated roster realm. - - "src/ingestion/tools/seed/insight_seed/keycloak_realm.py" + # The e2e Keycloak imports the realm generated by the seed package. + - "src/ingestion/tools/seed/insight_seed/**" - ".github/workflows/gateway.yml" workflow_dispatch: diff --git a/deploy/gitops/environments/functional-ci/values.yaml b/deploy/gitops/environments/functional-ci/values.yaml index 08aa39523..7a8e14d96 100644 --- a/deploy/gitops/environments/functional-ci/values.yaml +++ b/deploy/gitops/environments/functional-ci/values.yaml @@ -44,26 +44,20 @@ authenticator: name: local-ca kind: ClusterIssuer # IdP = the in-stack Keycloak (below). No browser participates in this - # smoke, so the issuer is the cluster-internal Service URL — the - # authenticator's per-login discovery is the only consumer. clientSecret - # is the dev secret the realm generator bakes into the generated realm - # (keycloak-realm make target); not sensitive. + # smoke, so the issuer is the cluster-internal Service URL. clientSecret is + # the dev secret the realm generator bakes in; not sensitive. oidc: issuerUrl: 'http://{{ .Release.Name }}-keycloak.{{ .Release.Namespace }}.svc.cluster.local:8085/kc/realms/insight' clientId: "insight-authenticator" clientSecret: "insight-authenticator-dev-secret" redirectUri: "http://localhost/auth/callback" - # The realm generator pins each realm user's `sub` to their roster uuid, - # and persons-seed fixtures key their `value_type='id'` rows on this - # source_type. + # Realm users' `sub` is their roster uuid; persons-seed fixtures key + # their id rows on this source_type. sourceType: "keycloak" externalIdClaim: "sub" -# keycloak — the in-stack Keycloak (roster realm generated by the -# keycloak-realm make target, applied via keycloak-config-cli like every env). -# `hostname` is the advertised issuer base; no ingress in this cluster, so it -# is the concrete Service FQDN (release/namespace pinned in inventory.yaml — -# the keycloak-realm recipe reads this value raw, so no tpl string here). +# `hostname` is the advertised issuer base; no ingress here, so the concrete +# Service FQDN (the keycloak-realm recipe reads it raw — no tpl string). keycloak: deploy: true hostname: 'http://insight-keycloak.insight.svc.cluster.local:8085/kc' diff --git a/dev-compose.sh b/dev-compose.sh index f4c001256..e5302a216 100755 --- a/dev-compose.sh +++ b/dev-compose.sh @@ -457,17 +457,11 @@ cmd_up() { [[ -n "$frontend_mode_override" ]] && FRONTEND_MODE="$frontend_mode_override" FRONTEND_MODE="${FRONTEND_MODE:-dev}" - # Auth always runs via Keycloak. A lingering non-keycloak AUTH_MODE in an - # old .env.compose is overridden, loudly. + # A lingering AUTH_MODE in an old .env.compose is dead config; warn, loudly. if [[ "${AUTH_MODE:-keycloak}" != "keycloak" ]]; then echo "WARN: AUTH_MODE=${AUTH_MODE} is retired — auth always runs via Keycloak." >&2 echo " Remove AUTH_MODE from $env_file to silence this." >&2 fi - AUTH_MODE="keycloak" - # The seed-sample container reads AUTH_MODE too (src/ingestion/tools/seed/profiles.py's - # get_login_id_pairs) to pick which roster personas get a login-id fixture — - # export so the child `docker compose` process's env-var interpolation sees it. - export AUTH_MODE # NGINX_BFF: Keycloak needs NO special frontend. The SPA is cookie/BFF # (same-origin): it calls /auth/login + /api through the gateway and never diff --git a/docker-compose.yml b/docker-compose.yml index 724c4ad1e..9ebbeec50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -683,10 +683,6 @@ services: # above) — the dev-lead's login-bootstrap value_type='id' row is seeded # under this source_type. IDP_SOURCE_TYPE: "${AUTHENTICATOR_IDP_SOURCE_TYPE:-keycloak}" - # Keycloak mode seeds a login-id fixture for the whole roster, each - # persona on their own uuid — see src/ingestion/tools/seed/profiles.py's - # get_login_id_pairs. dev-compose.sh exports this. - AUTH_MODE: "${AUTH_MODE:-keycloak}" # MariaDB — falls back to the local docker service name/port; the # wizard rewrites these when external MariaDB is selected. MARIADB_HOST: "${MARIADB_HOST:-mariadb}" diff --git a/src/backend/services/gateway/tests/conftest.py b/src/backend/services/gateway/tests/conftest.py index d6186ddae..2c149ec7b 100644 --- a/src/backend/services/gateway/tests/conftest.py +++ b/src/backend/services/gateway/tests/conftest.py @@ -43,17 +43,14 @@ KC_REALM = "insight" KC_DISCOVERY = f"{KEYCLOAK}/realms/{KC_REALM}/.well-known/openid-configuration" -# The realm generator's dev-lead persona; every realm user's password is the -# generator's baked dev password. +# The realm's dev-lead persona; the generator bakes one dev password for all users. E2E_USER = "dev@company.nonpresent" E2E_PASSWORD = "insight-dev" -# Every realm user carries a tenant claim and the generator requires one. This -# rig resolves people through the identity-stub (any external id resolves), so -# the value only has to be named; it is the one the compose stack uses. +# The generator requires a tenant; the identity-stub resolves any external id, +# so the value only has to be named. TENANT_ID = "00000000-df51-5b42-9538-d2b56b7ee953" -# Keycloak's login form posts to .../login-actions/authenticate. Matching on -# that rather than "the first " survives extra forms on the page. +# Anchored on login-actions/authenticate, not "the first ". _LOGIN_FORM = re.compile(r']+action="([^"]*login-actions/authenticate[^"]*)"', re.IGNORECASE) @@ -168,10 +165,10 @@ def _wait_http(url, want, timeout_s=90): def _generate_realm(import_dir: Path) -> None: - """Generate the Keycloak import realm with `insight-seed-realm` (uv resolves - and installs the seed package on first use). The compose default redirect - URIs would deregister the gateway callback (--authenticator-redirect - REPLACES, not appends), so it is passed explicitly.""" + """Generate the Keycloak import realm with `insight-seed-realm`. + + The redirect is passed explicitly: --authenticator-redirect REPLACES the + defaults, which would deregister the gateway callback.""" seed = REPO_ROOT / "src" / "ingestion" / "tools" / "seed" import_dir.mkdir(exist_ok=True) subprocess.run( diff --git a/src/backend/services/gateway/tests/docker-compose.e2e.yml b/src/backend/services/gateway/tests/docker-compose.e2e.yml index 5268e52d7..6ca6303ce 100644 --- a/src/backend/services/gateway/tests/docker-compose.e2e.yml +++ b/src/backend/services/gateway/tests/docker-compose.e2e.yml @@ -33,10 +33,8 @@ services: environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin - # The advertised issuer is the in-network origin, so the authenticator's - # discovery, token exchange, and the id_token `iss` all agree. The - # host-side pytest rewrites keycloak:8085 to the published port when it - # follows the browser-facing redirects and the login-form action. + # In-network origin, so discovery, token exchange, and the id_token `iss` + # agree; the host-side pytest rewrites it to the published port. KC_HOSTNAME: "http://keycloak:8085" KC_HTTP_ENABLED: "true" KC_HTTP_PORT: "8085" diff --git a/src/backend/services/gateway/tests/downstream-verify/README.md b/src/backend/services/gateway/tests/downstream-verify/README.md index 8896f90af..1d8fee2f8 100644 --- a/src/backend/services/gateway/tests/downstream-verify/README.md +++ b/src/backend/services/gateway/tests/downstream-verify/README.md @@ -42,7 +42,7 @@ keycloak ─▶ authenticator ─▶ gateway ─▶ {analytics, identity-resolut Requires `docker`, `openssl`, `pytest`, `uv` (realm generation), and — for scenario 4 — `PyJWT` + `cryptography`: -``` +```bash pip install pytest pyjwt cryptography src/backend/services/gateway/tests/downstream-verify/run-e2e.sh ``` diff --git a/src/backend/services/gateway/tests/downstream-verify/conftest.py b/src/backend/services/gateway/tests/downstream-verify/conftest.py index 16a4bbce9..c55e1ec48 100644 --- a/src/backend/services/gateway/tests/downstream-verify/conftest.py +++ b/src/backend/services/gateway/tests/downstream-verify/conftest.py @@ -64,15 +64,13 @@ KC_REALM = "insight" KC_DISCOVERY = f"{KEYCLOAK}/realms/{KC_REALM}/.well-known/openid-configuration" -# The realm generator's dev-lead persona; every realm user's password is the -# generator's baked dev password. +# The realm's dev-lead persona; the generator bakes one dev password for all users. E2E_USER = "dev@company.nonpresent" E2E_PASSWORD = "insight-dev" # The realm users' tenant claim; also the tenant of TENANT_DEV in the tests. TENANT_ID = "00000000-df51-5b42-9538-d2b56b7ee953" -# Keycloak's login form posts to .../login-actions/authenticate. Matching on -# that rather than "the first " survives extra forms on the page. +# Anchored on login-actions/authenticate, not "the first ". _LOGIN_FORM = re.compile(r']+action="([^"]*login-actions/authenticate[^"]*)"', re.IGNORECASE) @@ -174,10 +172,10 @@ def _wait_http(url, want, timeout_s=120): def _generate_realm(import_dir: Path) -> None: - """Generate the Keycloak import realm with `insight-seed-realm` (uv resolves - and installs the seed package on first use). The compose default redirect - URIs would deregister the gateway callback (--authenticator-redirect - REPLACES, not appends), so it is passed explicitly.""" + """Generate the Keycloak import realm with `insight-seed-realm`. + + The redirect is passed explicitly: --authenticator-redirect REPLACES the + defaults, which would deregister the gateway callback.""" seed = REPO_ROOT / "src" / "ingestion" / "tools" / "seed" import_dir.mkdir(exist_ok=True) subprocess.run( diff --git a/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml b/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml index b487aec1e..bda816d3d 100644 --- a/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml +++ b/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml @@ -47,10 +47,8 @@ services: environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin - # The advertised issuer is the in-network origin, so the authenticator's - # discovery, token exchange, and the id_token `iss` all agree. The - # host-side pytest rewrites keycloak:8085 to the published port when it - # follows the browser-facing redirects and the login-form action. + # In-network origin, so discovery, token exchange, and the id_token `iss` + # agree; the host-side pytest rewrites it to the published port. KC_HOSTNAME: "http://keycloak:8085" KC_HTTP_ENABLED: "true" KC_HTTP_PORT: "8085" diff --git a/src/ingestion/tests/e2e/lib/api_coverage.py b/src/ingestion/tests/e2e/lib/api_coverage.py index 82980a79f..89436f44d 100755 --- a/src/ingestion/tests/e2e/lib/api_coverage.py +++ b/src/ingestion/tests/e2e/lib/api_coverage.py @@ -152,10 +152,8 @@ # stamping), so there is no universal boilerplate to subtract. Its rate # limiter's 429s are real but undeclared — extra observed codes are ignored. AUTHENTICATOR_UNIVERSAL_BOILERPLATE = frozenset() -# back-channel-logout's 200 is answered to the IdP's server-side POST (proven -# in e2e_backchannel: Keycloak fires the signed logout_token and the user's -# sessions die), never to the test client — the client can only observe the -# 400 rejection. +# back-channel-logout's 200 is answered to the IdP's server-side POST +# (covered in e2e_backchannel), never to the test client. AUTHENTICATOR_BLOCKED: dict[str, frozenset[int]] = {"POST /auth/oidc/back-channel-logout": frozenset({200})} AUTHENTICATOR_REQUIRED_EXTRA: dict[str, frozenset[int]] = {} diff --git a/tests/lib/insight_stand/session.py b/tests/lib/insight_stand/session.py index e8f1ff784..ab03788fa 100644 --- a/tests/lib/insight_stand/session.py +++ b/tests/lib/insight_stand/session.py @@ -151,7 +151,7 @@ def _start(self, client: httpx.Client) -> str: if response.status_code not in (301, 302, 303, 307, 308) or not location: raise LoginNotCompletedError( f"GET {self.login_path} did not redirect to the IdP " - f"(status {response.status_code}); is Keycloak up on this stand?", + f"(status {response.status_code})", stopped_at=str(response.url), ) return str(location)