Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cf-studio/config/artifacts.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/file-bug-insight/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?" |

Expand Down
2 changes: 1 addition & 1 deletion .env.compose.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/functional-k3s.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,17 +187,29 @@ 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}"

# 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
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: |
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/gateway.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ on:
paths:
- "src/backend/tools/routegen/**"
- "src/backend/services/gateway/**"
# The e2e Keycloak imports the realm generated by the seed package.
- "src/ingestion/tools/seed/insight_seed/**"
- ".github/workflows/gateway.yml"
workflow_dispatch:

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
84 changes: 44 additions & 40 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ files under `docs/components/<area>/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)
Expand All @@ -30,7 +30,7 @@ files under `docs/components/<area>/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)

Expand All @@ -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
Expand All @@ -65,7 +65,7 @@ Open <http://localhost:3000>. `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
Expand Down Expand Up @@ -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: <UUID> # 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: <idp> # authenticator.oidc.* at a real IdP
# <l2>.host / <l2>.port # only when <l2>.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).
Expand Down Expand Up @@ -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…│
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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://<host-ip>: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`)
Expand All @@ -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).

---

Expand All @@ -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 <step>`); 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 /
Expand Down Expand Up @@ -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.
Expand All @@ -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://<host-ip>: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://<host-ip>: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.

---

Expand Down Expand Up @@ -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.

Expand Down
7 changes: 2 additions & 5 deletions charts/insight/Chart.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
10 changes: 2 additions & 8 deletions charts/insight/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading
Loading