From fed9ce8af84f797f04d60820fc7a2c0464e99115 Mon Sep 17 00:00:00 2001 From: Anton Zelenov Date: Thu, 16 Jul 2026 18:29:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20nginx+auth=20full-auth=20stack=20?= =?UTF-8?q?(NGINX=5FBFF=20EPIC=20#1583)=20=E2=80=94=20verification,=20dev-?= =?UTF-8?q?compose=20+=20k8s,=20drop=20legacy=20api-gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed delivery of the NGINX_BFF work on this branch. Every request now carries an ES256 gateway JWT and every downstream verifies it — no auth_disabled anywhere. Step 07 — downstream verification (R1): - analytics verifies the gateway JWT via the published cf-gears-oidc-authn-plugin (ES256, single tenant_id, claim_mapping); identity (.NET) via JwtBearer (ES256, plain-http JWKS). Deleted the insight fork plugin + the authverify wrapper + the X-Tenant-ID selector; CI matrix greened. identity-resolution integrates the same downstream gateway-JWT auth. Full-auth dev-compose: - nginx `gateway` is the sole :8080 edge (auth_request -> authenticator, JWT injection); authn-tls sidecar fronts the authenticator discovery/JWKS over https for the plugin; login runs against fakeidp; the no-auth config is retired. - gateway boots without a frontend (lazy SPA upstream); login-time person resolution via a service-only Identity endpoint; build accepts multiple targets. Kubernetes (umbrella chart 0.4.0, BREAKING): - add gateway / authenticator / fakeidp subcharts; drop apiGateway; remove all authDisabled validators; frontend made conditional. - authenticator authn-tls sidecar (cert-manager local-ca) + analytics CA mount; gateway ingress + strip_prefix; one canonical issuer everywhere. - gitops: local + functional-ci overlays; compose-app-secrets.sh composes the authenticator config + identity gateway-auth env; sealed ES256 signing key. Legacy api-gateway removed from compose, the umbrella chart, and the repo (crate, Dockerfile, helm chart, workspace member, CI jobs). cf-gears-api-gateway (the toolkit REST-host library) and oidc-authn-plugin are retained. Local-k8s IdP decision (ADR-0001): fakeidp for all dev/test envs (CI, compose, local k8s — exposed via an ingress `/idp` rewrite with a localhost callback for the __Host-sid secure-context rule; it injects tenant_id, zero extra infra). A production broker is deferred (authenticator IdP-agnostic; Casdoor/Keycloak noted as candidates); Dex and heavy-DB brokers evaluated and rejected. Verified on OrbStack (curl + real-browser Playwright): login -> session -> analytics + identity both 200, 401 without. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Anton Zelenov Addressed PR #1777 review feedback: - gateway routing: prefix + service + service-url (no double v1) — `/api/analytics/v1/…`, `/api/identity/v1/…` (strip_prefix kept). - analytics → identity: migrated off the DEPRECATED `GET /v1/persons/{email}` to `POST /v1/profiles` (maps ProfileResponse → the existing Person model; analytics' own contract unchanged). - authenticator: reject service-token requests naming >1 tenant. - identity Helm: per-value `with` guards so an unset chart value can't clobber the secret-supplied gateway env. - ES256 (not RS256) corrected in analytics comments + the authenticator Helm chart signing-key docs. - frontend re-enabled (deploy=true) in CI + local overlays (image pulled from ghcr). - renamed the `step07` e2e suite → `downstream-verify`; exported AUTHZ_CACHE_MAX_AGE in its conftest. - docs: deleted the stale LOCAL_DEV.md; refreshed CONTRIBUTING.md to the nginx-gateway + fakeidp full-auth reality; trimmed noisy config comments. Deferred (spec-governance, not gateway routing): the `/api/v1/` convention in the cfs-governed backend spec docs across all 8 services + the versioning NFR. authenticator /auth/me: include the person's `email` (stored in the session at login from the id_token) alongside user/tenants/roles, so the email-keyed SPA can self-locate under the cookie/BFF model. compose: default AUTHENTICATOR_REDIRECT_URI to the SPA's browser origin (http://localhost:3000/auth/callback) so the __Host-sid cookie lands where the SPA runs; document the browser-login prerequisites (redirect origin + a `127.0.0.1 fakeidp` hosts entry so the browser resolves the fakeidp issuer). gitignore: ignore the whole dev-compose key/cert dirs (authenticator-dev-keys/, authn-tls-certs/) rather than just *.pem, so a stray non-.pem key generated at runtime can't be swept in by `git add -A`. dev-compose: browser OIDC works from scratch. `up` auto-detects the host IP and defaults FAKEIDP_ISSUER + AUTHENTICATOR_OIDC_ISSUER to http://:8084 — an IP literal the browser reaches without an HTTPS-upgrade (a hostname is upgraded and fails against http-only fakeidp; localhost is the container itself) and the containers reach too. Skipped when an issuer is pinned (real IdP) or no host IP is found (offline → falls back to fakeidp:8084 for the curl/e2e path). fix(dev-compose): analytics home_dir out of /app so auto-reload doesn't crash-loop. Compose watchexec watches /app (the binary's dir); with home_dir=/app/data the gear's own startup writes tripped a restart mid-migration → concurrent migrators → Duplicate column/key → init crash-loop (and a log flood). Move it to /tmp. fix(gateway): frontUrl must be a FQDN. routegen emits the SPA proxy_pass through nginx's runtime resolver, which ignores /etc/resolv.conf search domains, so the short name `insight-frontend` failed to resolve (502 on /). Use the release/namespace/clusterDomain FQDN like authenticatorUrl. Signed-off-by: Anton Zelenov --- .cf-studio/config/artifacts.toml | 5 + .env.compose.example | 43 +- .github/workflows/build-images.yml | 206 +-------- .github/workflows/ci.yml | 7 +- .gitignore | 6 +- CONTRIBUTING.md | 156 +++++-- charts/insight/Chart.lock | 14 +- charts/insight/Chart.yaml | 32 +- charts/insight/templates/NOTES.txt | 22 +- charts/insight/templates/_helpers.tpl | 53 +-- charts/insight/templates/platform-config.yaml | 3 +- charts/insight/templates/secrets.yaml | 37 ++ charts/insight/values.yaml | 104 +++-- deploy/compose/analytics-fullauth.yaml | 99 +++++ deploy/compose/authn-tls.conf | 31 ++ deploy/compose/front-ghcr-patch-template.sh | 10 +- deploy/compose/gateway/routes.yaml | 8 +- deploy/compose/insight-init.sh | 4 +- .../environments/functional-ci/values.yaml | 28 +- .../local/inventory.yaml.template | 9 +- .../environments/local/values.yaml.template | 103 +++-- deploy/gitops/scripts/compose-app-secrets.sh | 58 +++ deploy/gitops/secrets-store.yaml.template | 25 +- dev-compose.sh | 129 ++++-- docker-compose.yml | 110 +++-- .../backend/authenticator/DESIGN.md | 53 ++- .../ADR/0001-per-environment-idp-selection.md | 187 ++++++++ docs/components/backend/gateway/DESIGN.md | 12 +- scripts/ci/changed.py | 31 +- scripts/ci/components.py | 27 +- src/backend/Cargo.lock | 276 ++++++++---- src/backend/Cargo.toml | 9 +- .../plugins/oidc-authn-plugin/Cargo.toml | 40 -- .../plugins/oidc-authn-plugin/README.md | 105 ----- .../plugins/oidc-authn-plugin/src/config.rs | 139 ------ .../oidc-authn-plugin/src/domain/client.rs | 53 --- .../oidc-authn-plugin/src/domain/mod.rs | 2 - .../oidc-authn-plugin/src/domain/service.rs | 285 ------------ .../plugins/oidc-authn-plugin/src/lib.rs | 12 - .../plugins/oidc-authn-plugin/src/module.rs | 124 ------ src/backend/services/LOCAL_DEV.md | 197 --------- src/backend/services/analytics/Cargo.toml | 19 +- src/backend/services/analytics/Dockerfile | 6 +- .../services/analytics/config/insight.yaml | 74 +++- .../analytics/helm/templates/configmap.yaml | 64 ++- .../analytics/helm/templates/deployment.yaml | 18 + .../services/analytics/helm/values.yaml | 37 +- .../services/analytics/src/api/handlers.rs | 19 +- .../analytics/src/api/http_live_tests.rs | 47 +- src/backend/services/analytics/src/api/mod.rs | 19 +- .../src/api/tenant_resolution_tests.rs | 160 ------- src/backend/services/analytics/src/auth.rs | 111 ----- .../services/analytics/src/domain/auth.rs | 14 +- .../analytics/src/infra/identity/mod.rs | 128 +++++- src/backend/services/analytics/src/main.rs | 19 +- src/backend/services/api-gateway/Cargo.toml | 50 --- src/backend/services/api-gateway/Dockerfile | 113 ----- src/backend/services/api-gateway/README.md | 182 -------- .../services/api-gateway/config/insight.yaml | 118 ----- .../services/api-gateway/config/no-auth.yaml | 81 ---- .../services/api-gateway/helm/Chart.yaml | 6 - .../api-gateway/helm/templates/_helpers.tpl | 13 - .../api-gateway/helm/templates/configmap.yaml | 132 ------ .../helm/templates/deployment.yaml | 81 ---- .../api-gateway/helm/templates/secret.yaml | 19 - .../services/api-gateway/helm/values.yaml | 89 ---- .../services/api-gateway/secrets/.gitignore | 4 - .../api-gateway/secrets/oidc.yaml.example | 35 -- .../services/api-gateway/specs/DESIGN.md | 417 ------------------ .../services/api-gateway/src/auth_info.rs | 141 ------ src/backend/services/api-gateway/src/main.rs | 89 ---- src/backend/services/api-gateway/src/proxy.rs | 313 ------------- src/backend/services/authenticator/Cargo.toml | 10 +- src/backend/services/authenticator/Dockerfile | 7 +- .../authenticator/helm/templates/_helpers.tpl | 4 + .../helm/templates/authn-tls.yaml | 55 +++ .../helm/templates/deployment.yaml | 32 ++ .../authenticator/helm/templates/service.yaml | 9 + .../services/authenticator/helm/values.yaml | 24 + .../authenticator/src/api/handlers.rs | 39 +- .../services/authenticator/src/api/mod.rs | 17 +- .../services/authenticator/src/gear.rs | 10 +- .../services/authenticator/src/identity.rs | 63 ++- src/backend/services/authenticator/src/jwt.rs | 72 ++- .../authenticator/src/service_token.rs | 52 ++- .../services/authenticator/src/session.rs | 5 + .../authenticator/tests/e2e_login_loop.rs | 4 +- .../services/authenticator/tests/run-e2e.sh | 2 + src/backend/services/fakeidp/Dockerfile | 4 - src/backend/services/fakeidp/helm/Chart.yaml | 6 + .../fakeidp/helm/templates/_helpers.tpl | 13 + .../fakeidp/helm/templates/deployment.yaml | 40 ++ .../fakeidp/helm/templates/ingress.yaml | 38 ++ .../helm/templates/service.yaml | 6 +- src/backend/services/fakeidp/helm/values.yaml | 42 ++ .../helm/templates/ingress.yaml | 12 +- src/backend/services/gateway/helm/values.yaml | 27 +- .../services/gateway/tests/conftest.py | 16 +- .../gateway/tests/downstream-verify/README.md | 49 ++ .../downstream-verify/analytics.e2e.yaml | 101 +++++ .../tests/downstream-verify/authn-tls.conf | 26 ++ .../tests/downstream-verify/conftest.py | 310 +++++++++++++ .../downstream-verify/docker-compose.e2e.yml | 174 ++++++++ .../tests/downstream-verify/pytest.ini | 11 + .../tests/downstream-verify/routes.e2e.yaml | 18 + .../tests/downstream-verify/run-e2e.sh | 14 + .../downstream-verify/test_downstream.py | 112 +++++ src/backend/services/gateway/tests/pytest.ini | 5 + .../services/gateway/tests/routes.e2e.yaml | 4 +- .../services/gateway/tests/test_gateway.py | 16 +- .../services/identity-resolution/Cargo.toml | 10 +- .../identity-resolution/config/insight.yaml | 57 ++- .../identity-resolution/src/api/mod.rs | 15 +- .../services/identity-resolution/src/auth.rs | 60 --- .../services/identity-resolution/src/main.rs | 1 - .../identity/helm/templates/deployment.yaml | 14 + .../services/identity/helm/values.yaml | 14 +- .../Auth/GatewayTenantContext.cs | 37 ++ .../Auth/HeaderCallerContext.cs | 123 ------ .../Auth/HeaderTenantContext.cs | 27 -- .../Auth/JwksRetriever.cs | 27 ++ .../Auth/JwtTenantContext.cs | 25 -- .../Auth/SubjectCallerContext.cs | 35 ++ .../Configuration/AppOptions.cs | 22 +- .../Endpoints/EndpointHelpers.cs | 2 +- .../Endpoints/PersonsEndpoints.cs | 51 ++- .../Endpoints/SubchartEndpoints.cs | 8 +- .../src/Insight.Identity.Api/Program.cs | 124 +++--- .../src/Insight.Identity.Api/appsettings.yaml | 7 + .../Services/IPersonsReader.cs | 12 + .../MariaDb/PersonsRepository.cs | 11 + .../MariaDb/Sql.cs | 27 ++ .../JwtCallerResolveTests.cs | 275 ------------ .../PersonsEndpointTests.cs | 39 +- .../ProfilesEndpointTests.cs | 1 - .../TestApplicationFactory.cs | 93 +++- .../GatewayTenantContextTests.cs | 54 +++ .../HeaderCallerContextTests.cs | 163 ------- .../HeaderTenantContextTests.cs | 60 --- .../JwtTenantContextTests.cs | 61 --- .../ProfileLookupServiceTests.cs | 3 + .../SubjectCallerContextTests.cs | 53 +++ src/backend/tools/routegen/src/emit.rs | 22 +- .../routegen/tests/fixtures/full.nginx.conf | 12 +- .../routegen/tests/fixtures/full.routes.yaml | 4 +- .../tests/fixtures/stripprefix.nginx.conf | 4 +- src/backend/tools/routegen/tests/golden.rs | 5 +- src/ingestion/tests/e2e/api/conftest.py | 21 +- .../tests/e2e/api/test_admin_thresholds.py | 30 +- src/ingestion/tests/e2e/conftest.py | 24 +- src/ingestion/tests/e2e/lib/analytics.py | 170 +++++-- .../tests/e2e/lib/collect_metrics.py | 21 +- src/ingestion/tests/e2e/lib/config.py | 22 +- src/ingestion/tests/e2e/lib/gateway_jwt.py | 191 ++++++++ .../tests/e2e/lib/metric_coverage.py | 92 ++-- src/ingestion/tests/e2e/lib/metric_seed.py | 9 +- src/ingestion/tests/e2e/pyproject.toml | 4 + 157 files changed, 4046 insertions(+), 5017 deletions(-) create mode 100644 deploy/compose/analytics-fullauth.yaml create mode 100644 deploy/compose/authn-tls.conf create mode 100644 docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md mode change 100644 => 100755 scripts/ci/changed.py delete mode 100644 src/backend/plugins/oidc-authn-plugin/Cargo.toml delete mode 100644 src/backend/plugins/oidc-authn-plugin/README.md delete mode 100644 src/backend/plugins/oidc-authn-plugin/src/config.rs delete mode 100644 src/backend/plugins/oidc-authn-plugin/src/domain/client.rs delete mode 100644 src/backend/plugins/oidc-authn-plugin/src/domain/mod.rs delete mode 100644 src/backend/plugins/oidc-authn-plugin/src/domain/service.rs delete mode 100644 src/backend/plugins/oidc-authn-plugin/src/lib.rs delete mode 100644 src/backend/plugins/oidc-authn-plugin/src/module.rs delete mode 100644 src/backend/services/LOCAL_DEV.md delete mode 100644 src/backend/services/analytics/src/api/tenant_resolution_tests.rs delete mode 100644 src/backend/services/analytics/src/auth.rs delete mode 100644 src/backend/services/api-gateway/Cargo.toml delete mode 100644 src/backend/services/api-gateway/Dockerfile delete mode 100644 src/backend/services/api-gateway/README.md delete mode 100644 src/backend/services/api-gateway/config/insight.yaml delete mode 100644 src/backend/services/api-gateway/config/no-auth.yaml delete mode 100644 src/backend/services/api-gateway/helm/Chart.yaml delete mode 100644 src/backend/services/api-gateway/helm/templates/_helpers.tpl delete mode 100644 src/backend/services/api-gateway/helm/templates/configmap.yaml delete mode 100644 src/backend/services/api-gateway/helm/templates/deployment.yaml delete mode 100644 src/backend/services/api-gateway/helm/templates/secret.yaml delete mode 100644 src/backend/services/api-gateway/helm/values.yaml delete mode 100644 src/backend/services/api-gateway/secrets/.gitignore delete mode 100644 src/backend/services/api-gateway/secrets/oidc.yaml.example delete mode 100644 src/backend/services/api-gateway/specs/DESIGN.md delete mode 100644 src/backend/services/api-gateway/src/auth_info.rs delete mode 100644 src/backend/services/api-gateway/src/main.rs delete mode 100644 src/backend/services/api-gateway/src/proxy.rs create mode 100644 src/backend/services/authenticator/helm/templates/authn-tls.yaml create mode 100644 src/backend/services/fakeidp/helm/Chart.yaml create mode 100644 src/backend/services/fakeidp/helm/templates/_helpers.tpl create mode 100644 src/backend/services/fakeidp/helm/templates/deployment.yaml create mode 100644 src/backend/services/fakeidp/helm/templates/ingress.yaml rename src/backend/services/{api-gateway => fakeidp}/helm/templates/service.yaml (52%) create mode 100644 src/backend/services/fakeidp/helm/values.yaml rename src/backend/services/{api-gateway => gateway}/helm/templates/ingress.yaml (61%) create mode 100644 src/backend/services/gateway/tests/downstream-verify/README.md create mode 100644 src/backend/services/gateway/tests/downstream-verify/analytics.e2e.yaml create mode 100644 src/backend/services/gateway/tests/downstream-verify/authn-tls.conf create mode 100644 src/backend/services/gateway/tests/downstream-verify/conftest.py create mode 100644 src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml create mode 100644 src/backend/services/gateway/tests/downstream-verify/pytest.ini create mode 100644 src/backend/services/gateway/tests/downstream-verify/routes.e2e.yaml create mode 100755 src/backend/services/gateway/tests/downstream-verify/run-e2e.sh create mode 100644 src/backend/services/gateway/tests/downstream-verify/test_downstream.py delete mode 100644 src/backend/services/identity-resolution/src/auth.rs create mode 100644 src/backend/services/identity/src/Insight.Identity.Api/Auth/GatewayTenantContext.cs delete mode 100644 src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderCallerContext.cs delete mode 100644 src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderTenantContext.cs create mode 100644 src/backend/services/identity/src/Insight.Identity.Api/Auth/JwksRetriever.cs delete mode 100644 src/backend/services/identity/src/Insight.Identity.Api/Auth/JwtTenantContext.cs create mode 100644 src/backend/services/identity/src/Insight.Identity.Api/Auth/SubjectCallerContext.cs delete mode 100644 src/backend/services/identity/tests/Insight.Identity.Tests.Integration/JwtCallerResolveTests.cs create mode 100644 src/backend/services/identity/tests/Insight.Identity.Tests.Unit/GatewayTenantContextTests.cs delete mode 100644 src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderCallerContextTests.cs delete mode 100644 src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderTenantContextTests.cs delete mode 100644 src/backend/services/identity/tests/Insight.Identity.Tests.Unit/JwtTenantContextTests.cs create mode 100644 src/backend/services/identity/tests/Insight.Identity.Tests.Unit/SubjectCallerContextTests.cs create mode 100644 src/ingestion/tests/e2e/lib/gateway_jwt.py diff --git a/.cf-studio/config/artifacts.toml b/.cf-studio/config/artifacts.toml index 79bf9889a..931e8bda3 100644 --- a/.cf-studio/config/artifacts.toml +++ b/.cf-studio/config/artifacts.toml @@ -592,6 +592,11 @@ path = "docs/components/backend/authenticator/DESIGN.md" name = "Authenticator Service Design" traceability = "DOCS-ONLY" +[[systems.artifacts]] +kind = "ADR" +path = "docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md" +name = "ADR-0001: Per-Environment IdP Selection (fakeidp for CI/compose, Dex for local k8s)" + [[systems.artifacts]] kind = "DESIGN" path = "docs/components/backend/gateway/DESIGN.md" diff --git a/.env.compose.example b/.env.compose.example index 09bb2fc25..2fdae4789 100644 --- a/.env.compose.example +++ b/.env.compose.example @@ -47,14 +47,14 @@ FRONTEND_IMAGE= # host-side build step, and generates an override that drops the bind # mount for the service. # -# Equivalent CLI: `./dev-compose.sh up --from-ghcr=api-gateway,identity` -API_GATEWAY_IMAGE= +# Equivalent CLI: `./dev-compose.sh up --from-ghcr=analytics,identity` ANALYTICS_IMAGE= IDENTITY_IMAGE= # ── Host port mapping ───────────────────────────────────────────────── # Change these if 8080/8081/8082/8084/3000/3306/8123/6379 are already in use. -API_GATEWAY_PORT=8080 +# GATEWAY_PORT is the nginx edge (the :8080 entry). +GATEWAY_PORT=8080 ANALYTICS_PORT=8081 IDENTITY_PORT=8082 # fakeidp — the dev/e2e fake OIDC provider (dev-only; never in production). @@ -118,13 +118,14 @@ SEEDED_LOCAL_CH= # Replace with the UUID present in your persons.insight_tenant_id. TENANT_DEFAULT_ID=00000000-df51-5b42-9538-d2b56b7ee953 -# The api-gateway runs no-auth.yaml (auth_disabled=true) — all requests get -# the default-tenant root context, and the frontend dev-impersonates -# VITE_DEV_USER_EMAIL (below). OIDC login via the authenticator is not wired -# up for the compose dev stack. +# Full auth (NGINX_BFF): the nginx `gateway` on :8080 is the entry — it does +# auth_request to the authenticator (which logs in against fakeidp), injects the +# gateway JWT, and every downstream service verifies it (no auth_disabled). Drive +# the login flow with curl (/auth/login -> fakeidp -> /auth/callback -> cookie -> +# /api/...), as the step-07 e2e does; the SPA login lands with the FE cutover. # # The OIDC_* vars below are read by the frontend (VITE_OIDC_ISSUER / -# VITE_OIDC_CLIENT_ID) but are unused while dev-impersonation is active. +# VITE_OIDC_CLIENT_ID). OIDC_ISSUER= # Also the authenticator's OIDC client_id (must equal fakeidp's default aud so # the id_token audience matches). Defaults to `insight-authenticator`. @@ -134,13 +135,25 @@ OIDC_AUDIENCE= OIDC_CLIENT_SECRET= # ── Authenticator (nginx+auth step 04) ──────────────────────────────── -# In compose the authenticator logs in against fakeidp. Discovery/JWKS/token -# are fetched in-network (fakeidp:8084); the browser-facing redirect_uri is -# host-visible. Login requires the person to exist in Identity — seed the DB -# (./dev-compose.sh seed identity); an unknown person is denied (403). -AUTHENTICATOR_OIDC_ISSUER=http://fakeidp:8084 -AUTHENTICATOR_REDIRECT_URI=http://localhost:8083/auth/callback -AUTHENTICATOR_GATEWAY_ISSUER=http://localhost:8080 +# In compose the authenticator logs in against fakeidp. Login requires the +# person to exist in Identity — seed the DB (./dev-compose.sh seed identity); +# an unknown person is denied (403). +# +# fakeidp issuer: LEAVE UNSET. `./dev-compose.sh up` auto-detects the host IP and +# sets FAKEIDP_ISSUER + AUTHENTICATOR_OIDC_ISSUER to http://:8084 — an IP +# the BROWSER reaches without an HTTPS-upgrade (a hostname like fakeidp:8084 gets +# upgraded and fails; localhost means the container itself) and the containers +# reach too. Set this ONLY to pin a real IdP (then auto-detect is skipped). +# AUTHENTICATOR_OIDC_ISSUER=https://your-idp.example/realm +# redirect_uri MUST be the origin the BROWSER loads the SPA from, so the +# `__Host-sid` session cookie lands on the SPA's origin (not the authenticator's +# own :8083). FRONTEND_MODE=dev serves the SPA via Vite at :3000 (which proxies +# /auth + /api to the gateway), so the callback rides that origin. If you browse +# the gateway edge instead, use http://localhost:8080/auth/callback. +AUTHENTICATOR_REDIRECT_URI=http://localhost:3000/auth/callback +# Token `iss` = the TLS discovery front (analytics' plugin resolves JWKS over +# https). The browser still reaches the gateway plainly at :8080. +AUTHENTICATOR_GATEWAY_ISSUER=https://authn-tls:8443 AUTHENTICATOR_IDENTITY_URL=http://identity:8082 # ── Dev impersonation ───────────────────────────────────────────────── diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 4c2ac4c90..02042768d 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -43,10 +43,10 @@ env: IMAGE_PREFIX: ghcr.io/constructorfabric permissions: - contents: write # publish-chart auto-commits Chart.yaml bumps back to main - packages: write # publish-chart pushes the umbrella chart to GHCR - id-token: write # keyless OIDC identity for attest-build-provenance - attestations: write # store SLSA provenance in the repo attestation store + contents: write # publish-chart auto-commits Chart.yaml bumps back to main + packages: write # publish-chart pushes the umbrella chart to GHCR + id-token: write # keyless OIDC identity for attest-build-provenance + attestations: write # store SLSA provenance in the repo attestation store jobs: # ─── Backend / chart change detection ────────────────────────────────────── @@ -56,23 +56,18 @@ jobs: changes: runs-on: ubuntu-latest outputs: - api_gateway: ${{ steps.filter.outputs.api_gateway }} analytics: ${{ steps.filter.outputs.analytics }} authenticator: ${{ steps.filter.outputs.authenticator }} - identity: ${{ steps.filter.outputs.identity }} - toolbox: ${{ steps.filter.outputs.toolbox }} - umbrella: ${{ steps.filter.outputs.umbrella }} - build_tag: ${{ steps.tag.outputs.build_tag }} + identity: ${{ steps.filter.outputs.identity }} + toolbox: ${{ steps.filter.outputs.toolbox }} + umbrella: ${{ steps.filter.outputs.umbrella }} + build_tag: ${{ steps.tag.outputs.build_tag }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 id: filter with: filters: | - api_gateway: - - 'src/backend/services/api-gateway/**' - - 'src/backend/Cargo.toml' - - 'src/backend/Cargo.lock' analytics: - 'src/backend/services/analytics/**' - 'src/backend/Cargo.toml' @@ -119,7 +114,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.scan.outputs.matrix }} - any: ${{ steps.scan.outputs.any }} + any: ${{ steps.scan.outputs.any }} steps: - uses: actions/checkout@v4 with: @@ -130,9 +125,9 @@ jobs: - name: Scan descriptors and filter by changed paths id: scan env: - EVENT_BEFORE: ${{ github.event.before }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - GITHUB_EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_EVENT_NAME: ${{ github.event_name }} FRONTEND_TAG_INPUT: ${{ inputs.frontend_tag }} run: | set -euo pipefail @@ -192,116 +187,6 @@ jobs: # only — the tagged multi-arch manifest is assembled by the merge-* job # below once both legs finish. This avoids QEMU emulation entirely: # `cargo build` / `dotnet publish` run natively on each arch's runner. - backend-api-gateway: - needs: changes - if: | - needs.changes.outputs.api_gateway == 'true' - || (github.event_name == 'workflow_dispatch' && inputs.frontend_tag == '') - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-latest - platform: linux/amd64 - - arch: arm64 - runner: ubuntu-24.04-arm - platform: linux/arm64 - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - id: build - with: - context: src/backend - file: src/backend/services/api-gateway/Dockerfile - platforms: ${{ matrix.platform }} - # push-by-digest=true emits one untagged manifest per arch into - # GHCR; the merge job below joins them into the final tagged - # multi-arch manifest. push=false (PRs) → buildx still builds, - # validating the Dockerfile, but nothing is pushed. - outputs: type=image,name=${{ env.IMAGE_PREFIX }}/insight-api-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} - # Scope cache per arch so amd64 and arm64 don't evict each other. - cache-from: type=gha,scope=api-gateway-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=api-gateway-${{ matrix.arch }},ignore-error=true - - name: Export digest - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - name: Upload digest - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 - with: - name: digests-api-gateway-${{ matrix.arch }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - # Assemble the multi-arch manifest from the per-arch digests and attest - # the manifest. Runs only on main pushes (the per-arch legs don't push - # on PRs). attest-build-provenance binds to the manifest digest, so - # `gh attestation verify oci://:` covers both arches. - merge-api-gateway: - needs: [changes, backend-api-gateway] - if: | - always() - && needs.backend-api-gateway.result == 'success' - && github.event_name != 'pull_request' - && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digests-api-gateway-* - merge-multiple: true - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 - id: meta - with: - images: ${{ env.IMAGE_PREFIX }}/insight-api-gateway - tags: | - type=raw,value=${{ needs.changes.outputs.build_tag }} - type=raw,value=latest - - name: Create multi-arch manifest and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create \ - $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.IMAGE_PREFIX }}/insight-api-gateway@sha256:%s ' *) - - name: Inspect manifest digest - id: inspect - run: | - DIGEST=$(docker buildx imagetools inspect \ - ${{ env.IMAGE_PREFIX }}/insight-api-gateway:${{ needs.changes.outputs.build_tag }} \ - --format '{{json .Manifest}}' | jq -r .digest) - echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" - # Keyless SLSA provenance on the MANIFEST digest — covers both arches - # with one attestation (see backend-api-gateway in earlier commits for - # the keyless-OIDC contract). Verify: - # gh attestation verify oci://: --repo constructorfabric/insight - - name: Attest build provenance - uses: actions/attest-build-provenance@v2 - with: - subject-name: ${{ env.IMAGE_PREFIX }}/insight-api-gateway - subject-digest: ${{ steps.inspect.outputs.digest }} - push-to-registry: true - backend-analytics: needs: changes if: | @@ -521,7 +406,7 @@ jobs: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Context is src/backend/ (uniform with api-gateway + analytics) + # Context is src/backend/ (uniform with analytics) # so the Dockerfile can reach src/backend/docker-entrypoint.sh — # the shared ENABLE_AUTO_RELOAD-aware entrypoint added in the # docker-compose dev path. Internal COPY paths inside the @@ -798,13 +683,13 @@ jobs: id: build with: context: ${{ matrix.entry.connector_dir }}/${{ matrix.entry.context }} - file: ${{ matrix.entry.connector_dir }}/${{ matrix.entry.dockerfile }} + file: ${{ matrix.entry.connector_dir }}/${{ matrix.entry.dockerfile }} platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.entry.name }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} # Scope cache per (image name, arch) so unrelated connectors and # cross-arch builds don't evict each other. cache-from: type=gha,scope=${{ matrix.entry.name }}-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.entry.name }}-${{ matrix.arch }},ignore-error=true + cache-to: type=gha,mode=max,scope=${{ matrix.entry.name }}-${{ matrix.arch }},ignore-error=true - name: Export digest if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' run: | @@ -894,7 +779,6 @@ jobs: needs: - changes - discover-images - - merge-api-gateway - merge-analytics - merge-authenticator - merge-identity @@ -915,7 +799,6 @@ jobs: && github.ref == 'refs/heads/main' && needs.discover-images.outputs.any == 'true' && needs.merge-image.result == 'success' - && (needs.merge-api-gateway.result == 'success' || needs.merge-api-gateway.result == 'skipped') && (needs.merge-analytics.result == 'success' || needs.merge-analytics.result == 'skipped') && (needs.merge-authenticator.result == 'success' || needs.merge-authenticator.result == 'skipped') && (needs.merge-identity.result == 'success' || needs.merge-identity.result == 'skipped') @@ -945,9 +828,9 @@ jobs: - name: Patch images..image refs from matrix env: - BUILD_TAG: ${{ needs.changes.outputs.build_tag }} + BUILD_TAG: ${{ needs.changes.outputs.build_tag }} IMAGE_PREFIX: ${{ env.IMAGE_PREFIX }} - MATRIX: ${{ needs.discover-images.outputs.matrix }} + MATRIX: ${{ needs.discover-images.outputs.matrix }} run: | set -euo pipefail # Iterate every matrix entry (every built image this run) and patch @@ -987,7 +870,7 @@ jobs: - name: Commit descriptor patches (no [skip ci]) id: commit env: - MATRIX: ${{ needs.discover-images.outputs.matrix }} + MATRIX: ${{ needs.discover-images.outputs.matrix }} BUILD_TAG: ${{ needs.changes.outputs.build_tag }} run: | set -euo pipefail @@ -1051,7 +934,6 @@ jobs: needs: - changes - discover-images - - merge-api-gateway - merge-analytics - merge-authenticator - merge-identity @@ -1062,7 +944,6 @@ jobs: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' - && (needs.merge-api-gateway.result == 'success' || needs.merge-api-gateway.result == 'skipped') && (needs.merge-analytics.result == 'success' || needs.merge-analytics.result == 'skipped') && (needs.merge-authenticator.result == 'success' || needs.merge-authenticator.result == 'skipped') && (needs.merge-identity.result == 'success' || needs.merge-identity.result == 'skipped') @@ -1071,8 +952,7 @@ jobs: && (needs.bump-descriptors.result == 'success' || needs.bump-descriptors.result == 'skipped') && needs.bump-descriptors.outputs.committed != 'true' && ( - needs.changes.outputs.api_gateway == 'true' - || needs.changes.outputs.analytics == 'true' + needs.changes.outputs.analytics == 'true' || needs.changes.outputs.identity == 'true' || needs.changes.outputs.toolbox == 'true' || needs.changes.outputs.umbrella == 'true' @@ -1138,16 +1018,11 @@ jobs: # tag in the published chart still resolves to an image that exists. - name: Bump per-service subchart appVersions env: - BUILD_TAG: ${{ needs.changes.outputs.build_tag }} - API_GATEWAY: ${{ needs.changes.outputs.api_gateway }} + BUILD_TAG: ${{ needs.changes.outputs.build_tag }} ANALYTICS: ${{ needs.changes.outputs.analytics }} - IDENTITY: ${{ needs.changes.outputs.identity }} + IDENTITY: ${{ needs.changes.outputs.identity }} run: | set -euo pipefail - if [ "$API_GATEWAY" = "true" ]; then - echo "Bumping api-gateway subchart appVersion → $BUILD_TAG" - yq -i ".appVersion = \"$BUILD_TAG\"" src/backend/services/api-gateway/helm/Chart.yaml - fi if [ "$ANALYTICS" = "true" ]; then echo "Bumping analytics subchart appVersion → $BUILD_TAG" yq -i ".appVersion = \"$BUILD_TAG\"" src/backend/services/analytics/helm/Chart.yaml @@ -1199,41 +1074,7 @@ jobs: # is lexicographically sortable. - name: Bump umbrella version id: umbrella - run: | - set -euo pipefail - CURRENT="$(yq '.version' charts/insight/Chart.yaml)" - NEXT="$(echo "$CURRENT" | awk -F. '{ printf "%d.%d.%d", $1, $2, $3+1 }')" - # yq v4 emits a `---` document separator between results when given - # multiple files (behaviour appeared with a runner yq update, - # 2026-06-08): without the filter below, `sort -V | tail -1` picks - # the separator and `appVersion: "---"` ships in the published - # chart — an invalid `app.kubernetes.io/version` label value that - # breaks every install (charts 0.1.48–0.1.55 were affected). - NEW_APP=$(yq -r '.appVersion' \ - src/backend/services/api-gateway/helm/Chart.yaml \ - src/backend/services/analytics/helm/Chart.yaml \ - src/backend/services/identity/helm/Chart.yaml \ - src/frontend/helm/Chart.yaml \ - | grep -Ev '^(---|null)$' \ - | sort -V | tail -1) - # Fail loud unless the result is a build tag (YYYY.MM.DD.HH.MM-sha): - # a malformed appVersion must never reach a published chart again. - if ! echo "$NEW_APP" | grep -Eq '^[0-9]{4}(\.[0-9]{2}){4}-[0-9a-f]{7}$'; then - echo "ERROR: computed umbrella appVersion '$NEW_APP' is not a valid build tag" >&2 - exit 1 - fi - echo "Umbrella version $CURRENT → $NEXT (appVersion → $NEW_APP)" - yq -i ".version = \"$NEXT\"" charts/insight/Chart.yaml - yq -i ".appVersion = \"$NEW_APP\"" charts/insight/Chart.yaml - # Pin new version in docs and sample gitops - if [ -f docs/deploy/.insight-version ]; then - echo "$NEXT" > docs/deploy/.insight-version - fi - if [ -f deploy/gitops/.insight-version ]; then - echo "$NEXT" > deploy/gitops/.insight-version - fi - echo "version=${NEXT}" >> "$GITHUB_OUTPUT" - + run: "set -euo pipefail\nCURRENT=\"$(yq '.version' charts/insight/Chart.yaml)\"\nNEXT=\"$(echo \"$CURRENT\" | awk -F. '{ printf \"%d.%d.%d\", $1, $2, $3+1 }')\"\n# yq v4 emits a `---` document separator between results when given\n# multiple files (behaviour appeared with a runner yq update,\n# 2026-06-08): without the filter below, `sort -V | tail -1` picks\n# the separator and `appVersion: \"---\"` ships in the published\n# chart — an invalid `app.kubernetes.io/version` label value that\n# breaks every install (charts 0.1.48–0.1.55 were affected).\nNEW_APP=$(yq -r '.appVersion' \\\n src/backend/services/analytics/helm/Chart.yaml \\\n src/backend/services/identity/helm/Chart.yaml \\\n src/frontend/helm/Chart.yaml \\\n | grep -Ev '^(---|null)$' \\\n | sort -V | tail -1)\n# Fail loud unless the result is a build tag (YYYY.MM.DD.HH.MM-sha):\n# a malformed appVersion must never reach a published chart again.\nif ! echo \"$NEW_APP\" | grep -Eq '^[0-9]{4}(\\.[0-9]{2}){4}-[0-9a-f]{7}$'; then\n echo \"ERROR: computed umbrella appVersion '$NEW_APP' is not a valid build tag\" >&2\n exit 1\nfi\necho \"Umbrella version $CURRENT → $NEXT (appVersion → $NEW_APP)\"\nyq -i \".version = \\\"$NEXT\\\"\" charts/insight/Chart.yaml\nyq -i \".appVersion = \\\"$NEW_APP\\\"\" charts/insight/Chart.yaml\n# Pin new version in docs and sample gitops \nif [ -f docs/deploy/.insight-version ]; then\n echo \"$NEXT\" > docs/deploy/.insight-version\nfi\nif [ -f deploy/gitops/.insight-version ]; then\n echo \"$NEXT\" > deploy/gitops/.insight-version\nfi\necho \"version=${NEXT}\" >> \"$GITHUB_OUTPUT\"\n #magic___^_^___line\n" - name: Helm dependency update run: helm dependency update charts/insight @@ -1274,7 +1115,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} # Keyless SLSA provenance for the umbrella chart OCI artifact — same - # contract as the image attestations (see backend-api-gateway). Verify: + # contract as the image attestations (see backend-analytics). Verify: # gh attestation verify oci://ghcr.io/constructorfabric/charts/insight: \ # --repo constructorfabric/insight - name: Attest chart provenance @@ -1296,7 +1137,6 @@ jobs: git add charts/insight/Chart.yaml \ charts/insight/Chart.lock \ charts/insight/values.yaml \ - src/backend/services/api-gateway/helm/Chart.yaml \ src/backend/services/analytics/helm/Chart.yaml \ src/backend/services/identity/helm/Chart.yaml \ src/frontend/helm/Chart.yaml \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b4065858..630cafdcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,7 +190,7 @@ jobs: cargo llvm-cov report --cobertura $ignore \ --output-path "$GITHUB_WORKSPACE/coverage/rust-${{ matrix.entry.name }}.cobertura.xml" - # Changed crates with cover=false (api-gateway — no unit tests yet) still + # Changed crates with cover=false (authenticator — no in-process tests yet) still # run plain `cargo test`: a failure fails this job; only the Cobertura # collection and the coverage gates are skipped. Lint-only fanout entries # (test=false) skip this too — coverage/tests stay per-changed-crate. @@ -363,10 +363,7 @@ jobs: # unchanged language) report 'skipped', not 'failure', so they're fine. - name: Guard against incomplete reports if: >- - needs.changes.result != 'success' || - needs.rust.result == 'failure' || - needs.dotnet.result == 'failure' || - needs.python.result == 'failure' + needs.changes.result != 'success' || needs.rust.result == 'failure' || needs.dotnet.result == 'failure' || needs.python.result == 'failure' run: | echo "changes=${{ needs.changes.result }} rust=${{ needs.rust.result }} dotnet=${{ needs.dotnet.result }} python=${{ needs.python.result }}" echo "::error::Change detection or a producer job failed — refusing to gate on an incomplete report set." diff --git a/.gitignore b/.gitignore index 941fac362..a44de6d47 100644 --- a/.gitignore +++ b/.gitignore @@ -403,5 +403,7 @@ coverage-raw/ .windsurf/workflows/cf.md # END Constructor Studio -# Dev-only ES256 signing key generated by dev-compose.sh (never committed). -deploy/compose/authenticator-dev-keys/*.pem +# Dev-only key/cert material generated by dev-compose.sh (never committed) — +# whole dirs, so a stray non-.pem key can't slip past a git add -A. +deploy/compose/authenticator-dev-keys/ +deploy/compose/authn-tls-certs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d84236115..401c3de21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ files under `docs/components//specs/`. 7. [Seeding](#seeding) - [Compose](#compose) - [Kubernetes](#kubernetes) -8. [Dev auth chain (no-auth mode)](#dev-auth-chain-no-auth-mode) +8. [Dev auth chain (fakeidp)](#dev-auth-chain-fakeidp) 9. [Troubleshooting](#troubleshooting) 10. [Code style and reviews](#code-style-and-reviews) @@ -63,6 +63,13 @@ the Cargo cache and finish in seconds. 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; +> 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 +> containers + volumes for a clean slate. + --- ## Prerequisites @@ -163,8 +170,12 @@ 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 -# apiGateway.authDisabled: true # local sandbox; flip for real OIDC +# fakeidp.deploy: true # local sandbox 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 +# (`kubectl -n ingress-nginx get svc ingress-nginx-controller`). # 3. Cleartext secret store (read by `make seal`, never committed). cp secrets-store.yaml.template secrets-store.yaml @@ -194,8 +205,9 @@ every Deployment is Ready before the chain returns. │ Vite dev (HMR) / nginx+dist / ghcr image — port 3000 │ ├──────────────────────────────────────────────────────────────────────┤ │ Backend │ -│ api-gateway (Rust :8080) analytics (Rust :8081) │ +│ gateway (nginx :8080) analytics (Rust :8081) │ │ identity (.NET 9 :8082) │ +│ authenticator (Rust :8083/:8093) fakeidp (Rust :8084, dev-only) │ ├──────────────────────────────────────────────────────────────────────┤ │ Infra │ │ MariaDB :3306 ClickHouse :8123/:9000 Redis :6379 Redpanda :19092…│ @@ -266,12 +278,12 @@ wizard. To use it, hand-edit `FRONTEND_MODE=built` in `.env.compose`, Skip the local Rust/dotnet build for one or more services: ```bash -# Per-run flags -./dev-compose.sh up --from-ghcr=api-gateway,identity +# Per-run flags (recognised services: analytics, identity) +./dev-compose.sh up --from-ghcr=analytics,identity ./dev-compose.sh up --build-only=analytics # invert: build only this # Or pin in .env.compose -API_GATEWAY_IMAGE=ghcr.io/constructorfabric/insight-api-gateway:latest +ANALYTICS_IMAGE=ghcr.io/constructorfabric/insight-analytics:latest ``` The script writes `deploy/compose/override.generated.yml` (gitignored) that @@ -283,7 +295,7 @@ drops the `build:` + bind-mount for the chosen services. - **Auto-reload** — `ENABLE_AUTO_RELOAD` (compose-only, never in k8s) - **Frontend** — `FRONTEND_MODE`, `INSIGHT_FRONT_PATH`, `FRONTEND_IMAGE` -- **Backend image overrides** — `API_GATEWAY_IMAGE`, `ANALYTICS_IMAGE`, `IDENTITY_IMAGE` +- **Backend image overrides** — `ANALYTICS_IMAGE`, `IDENTITY_IMAGE` - **Host ports** — every published port is configurable - **Database mode** — `MARIADB_EXTERNAL`/`_HOST`/`_INTERNAL_PORT`/…, ClickHouse equivalents (see [External DBs](#external-mariadb--clickhouse)) - **Credentials** — local-only, kept in dotenv per project policy @@ -300,7 +312,9 @@ drops the `build:` + bind-mount for the chosen services. | Edit | Then | Picked up by | | --- | --- | --- | | Rust / C# source | `./dev-compose.sh build ` | watchexec → ~1s restart | -| `src/backend/services/api-gateway/config/*.yaml` | save | watchexec → ~1s restart (bind-mounted) | +| `deploy/compose/gateway/routes.yaml` (gateway route table) | `docker compose restart gateway` | nginx.conf regenerated at startup | +| `src/backend/services/authenticator/config/*.yaml` | save | watchexec → ~1s restart (bind-mounted) | +| `deploy/compose/analytics-fullauth.yaml` (analytics plugin config) | save | watchexec → ~1s restart (bind-mounted) | | identity / analytics env | edit `docker-compose.yml`, `up -d ` | container respawn | | Frontend (`dev` mode) | save | Vite HMR | | Frontend (`built` mode) | `./dev-compose.sh build frontend` | nginx auto | @@ -309,11 +323,11 @@ drops the `build:` + bind-mount for the chosen services. Build targets: ```bash -./dev-compose.sh build api-gateway # Rust gateway -./dev-compose.sh build analytics # Rust analytics +./dev-compose.sh build analytics # Rust analytics +./dev-compose.sh build authenticator # Rust authenticator ./dev-compose.sh build identity # .NET 9 publish ./dev-compose.sh build frontend # pnpm build → dist/ -./dev-compose.sh build rust # both Rust services +./dev-compose.sh build rust # all Rust services (analytics + authenticator) ./dev-compose.sh build all # everything ./dev-compose.sh up --skip-build # bounce without rebuilding ``` @@ -344,7 +358,7 @@ watchexec wants, and `useradd -m` ensures `appuser` has a usable ```bash # Tail logs -docker compose logs -f api-gateway analytics identity +docker compose logs -f gateway authenticator analytics identity fakeidp # Inspect databases docker compose exec mariadb mariadb -uinsight -pinsight-local identity @@ -356,7 +370,7 @@ docker compose exec clickhouse clickhouse-client --user insight --password insig ./dev-compose.sh prune # interactive nuke — see below # One-off cargo work -docker compose --profile build run --rm build-rust cargo test -p insight-api-gateway +docker compose --profile build run --rm build-rust cargo test -p analytics ``` `prune` is the only command that removes `.env.compose`. Always @@ -364,15 +378,23 @@ interactive — no `--yes` switch. Asks separately whether to also remove pulled `ghcr.io/constructorfabric/insight-*` images (defaults to no — they're slow to re-pull). After prune, next `up` re-runs the wizard. -### Switch the gateway to real OIDC +### 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: -Edit `src/backend/services/api-gateway/config/no-auth.yaml` directly -(bind-mounted): +- **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`. +- **K8s** — set `authenticator.oidc.issuerUrl` (+ `clientId` / + `redirectUri`) in the values overlay and set `fakeidp.deploy: false`. -1. Set `api-gateway.config.auth_disabled: false`. -2. Fill in `oidc-authn-plugin.config.issuer_url`, `audience`, and the - `auth-info` section's `client_id` / `scopes`. -3. Save — watchexec restarts in ~1 second. +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. --- @@ -462,36 +484,67 @@ resolves to a real person row and dashboards populate. --- -## Dev auth chain (no-auth mode) +## Dev auth chain (fakeidp) -When `AUTH_DISABLED=true` and `VITE_DEV_USER_EMAIL=you@yourorg.com`: +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. ```text -1. Browser → fetch-with-auth.ts builds an unsigned JWT - ({alg:"none"}.{email, sub, preferred_username}.) and sets - Authorization: Bearer on every request. -2. Vite → proxies /api/* to api-gateway via the compose network. -3. Gateway → auth_disabled=true skips JWT validation but forwards - the Authorization header end-to-end. -4. Service → identity's HeaderCallerContext falls back to JWT claims - when X-Insight-Person-Id is absent: reads email/sub/oid, - looks the value up in persons (value_type='email'), - returns the matching person_id. Tenant comes from - X-Insight-Tenant-Id or - IDENTITY__identity__tenant_default_id. +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 (VITE_DEV_USER_EMAIL) 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. + (`__Host-` requires a secure context — HTTPS or + http://localhost.) +4. Gateway → on every subsequent /api/* request it runs auth_request + against the authenticator, mints/attaches the ES256 + gateway JWT, and proxies to analytics / identity + the SPA. +5. Downstream→ analytics verifies the JWT via cf-gears-oidc-authn-plugin + (JWKS over the authn-tls discovery front); identity + verifies it with .NET JwtBearer against the authenticator's + JWKS endpoint. Both resolve the caller person from the + token claims. ``` -So three things must all be true for a dev call to succeed: - -- `VITE_DEV_USER_EMAIL` is set (FE builds the bearer token). +**Driving it from a real browser (not just curl)** works out of the box — two +things a browser needs that curl doesn't are handled automatically: + +- **The callback rides the SPA's own origin** so the `__Host-sid` cookie lands + where the SPA runs: `AUTHENTICATOR_REDIRECT_URI` defaults to + `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.) + +So a dev call succeeds when: + +- The stack is up with `fakeidp` (default profile) 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 - that address (run `./dev-compose.sh seed identity`). -- The gateway proxies `/api/{prefix}` to the right upstream (see - `no-auth.yaml`). + `VITE_DEV_USER_EMAIL` (run `./dev-compose.sh seed identity` — fakeidp's + default login resolves to that seeded person). +- The gateway's `routes.yaml` proxies `/api/{prefix}` to the right + upstream (`deploy/compose/gateway/routes.yaml`). -If you bypass the FE (curl from the host), you must construct the same -fake bearer yourself; otherwise identity returns -`401 caller_unresolved`. +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. --- @@ -511,11 +564,18 @@ Image out-of-date (the Dockerfile pins the musl static build of watchexec and creates a home dir for `appuser`). Force a rebuild: `docker compose build --no-cache `. -**api-gateway exits with `oidc-authn-plugin: issuer_url is required`.** -`no-auth.yaml` ships with a placeholder issuer because the plugin -module is registered even when `auth_disabled=true`. Restore the -`issuer_url: https://no-auth.local/oauth2/default` line — the URL is -never actually called. +**`authenticator` exits at startup / login fails.** +The authenticator needs its dev ES256 signing key +(`deploy/compose/authenticator-dev-keys/current.pem`) and the analytics +plugin needs the authn-tls discovery cert +(`deploy/compose/authn-tls-certs/`). Both are generated by +`./dev-compose.sh up` (never committed). If they're missing or stale, +re-run `up` (or `prune` then `up`) so the key/cert are regenerated. + +**Login returns 403 / "person not found".** +`fakeidp`'s default login identity (`VITE_DEV_USER_EMAIL`) must resolve +to a seeded person in identity. Run `./dev-compose.sh seed identity` +first — an unknown person is denied. **Frontend dev mode hangs at "pnpm install".** First-run installs all deps into the named volume; can take several diff --git a/charts/insight/Chart.lock b/charts/insight/Chart.lock index d6170d23a..c9045a258 100644 --- a/charts/insight/Chart.lock +++ b/charts/insight/Chart.lock @@ -1,6 +1,12 @@ dependencies: -- name: insight-api-gateway - repository: file://../../src/backend/services/api-gateway/helm +- name: insight-gateway + repository: file://../../src/backend/services/gateway/helm + version: 0.1.0 +- 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-analytics repository: file://../../src/backend/services/analytics/helm @@ -11,5 +17,5 @@ dependencies: - name: insight-frontend repository: file://../../src/frontend/helm version: 0.1.0 -digest: sha256:fe702b3761feeb33328e51945c6f0a6f8c293250b53317e31349b45a4cdf89f3 -generated: "2026-07-03T10:49:33.322019+02:00" +digest: sha256:ca2848a8b6fada086bd6cab42a9362d3e91545a739554cd06867466e4e81eae4 +generated: "2026-07-16T15:04:25.099184+02:00" diff --git a/charts/insight/Chart.yaml b/charts/insight/Chart.yaml index c391a91e1..d6382b496 100644 --- a/charts/insight/Chart.yaml +++ b/charts/insight/Chart.yaml @@ -5,7 +5,8 @@ ## Emits almost nothing on its own — it orchestrates subcharts. ## ## What is INCLUDED here: -## - Application: api-gateway, analytics, identity, frontend +## - Edge / auth: gateway (nginx+auth), authenticator, fakeidp (dev only) +## - Application: analytics, identity, frontend ## ## What is NOT included (deployed separately): ## - L2 infra (ClickHouse, MariaDB, Redis, Redpanda) — deployed as @@ -24,7 +25,7 @@ type: application # version — packaging version of the chart. Bump on EVERY chart change. # appVersion — product version. Matches the image tag of all insight-* images. # Release pipeline: git tag v0.1.0 → version=0.1.0, appVersion="0.1.0". -version: 0.3.54 +version: 0.4.0 appVersion: "2026.07.17.12.14-96a3da1" kubeVersion: ">=1.27.0-0" home: https://github.com/constructorfabric/insight @@ -54,16 +55,31 @@ maintainers: dependencies: # ─── Application services ────────────────────────────────────────────── # All aliases are camelCase so values.yaml stays clean. - # Service names inside the cluster stay kebab-case: `insight-api-gateway`. + # Service names inside the cluster stay kebab-case: `insight-gateway`. # # No `condition:` on app services: the umbrella is a single deployable # unit and every app service is mandatory (Constructor Platform / # SRE requirement). The gateway is the only entrance; the rest of the # services are reachable only through it. - - name: insight-api-gateway - alias: apiGateway + # nginx+auth edge (NGINX_BFF, EPIC #1583). The `gateway` (OpenResty) is the + # 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). + - name: insight-gateway + alias: gateway version: "0.1.0" - repository: "file://../../src/backend/services/api-gateway/helm" + repository: "file://../../src/backend/services/gateway/helm" + - name: insight-authenticator + 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 - name: insight-analytics alias: analytics version: "0.2.0" @@ -76,7 +92,11 @@ dependencies: version: "0.1.0" repository: "file://../../src/backend/services/identity/helm" condition: identity.deploy + # Frontend SPA. Conditional: the gateway serves it at "/", but the backend + # (/api/*) is fully usable without it (the gateway's front upstream resolves + # lazily), so a backend-only bring-up can run frontend.deploy=false. - name: insight-frontend alias: frontend version: "0.1.0" repository: "file://../../src/frontend/helm" + condition: frontend.deploy diff --git a/charts/insight/templates/NOTES.txt b/charts/insight/templates/NOTES.txt index 4c99baf11..8004fc9e2 100644 --- a/charts/insight/templates/NOTES.txt +++ b/charts/insight/templates/NOTES.txt @@ -15,9 +15,13 @@ Resolved infra endpoints (external L2, see ConfigMap {{ .Release.Name }}-platfor Airbyte: {{ include "insight.airbyte.url" . }} Application services (always-on): - ✓ API Gateway http://{{ include "insight.apiGateway.host" . }}:8080 + ✓ Gateway (edge) http://{{ include "insight.gateway.host" . }}:8080 + ✓ 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.identity.deploy }} ✓ Identity http://{{ include "insight.identity.host" . }}:8082 (deployed) {{- else }} @@ -36,19 +40,15 @@ Next steps: 1. Wait for all pods to be ready: kubectl -n {{ .Release.Namespace }} rollout status -l app.kubernetes.io/part-of=insight deploy --timeout=5m - 2. Open the frontend locally: - kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "insight.frontend.host" . }} 8080:80 + 2. Open the platform locally (everything enters through the gateway edge): + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "insight.gateway.host" . }} 8080:8080 3. Docs: https://github.com/constructorfabric/insight -{{- /* Only warn when auth is enabled, no existingSecret is set, - and inline issuer is also empty — i.e. operator hasn't picked - any of the three valid OIDC paths. */ -}} -{{- $oid := default dict .Values.apiGateway.oidc -}} -{{ if and (not .Values.apiGateway.authDisabled) (not $oid.existingSecret) (not $oid.issuer) -}} -⚠ WARNING: OIDC is not configured. For production set either - apiGateway.oidc.existingSecret OR all four inline fields - (issuer + audience + clientId + redirectUri). +{{- 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 .Values.credentials.autoGenerate }} diff --git a/charts/insight/templates/_helpers.tpl b/charts/insight/templates/_helpers.tpl index 5e4388c08..fc68ebd91 100644 --- a/charts/insight/templates/_helpers.tpl +++ b/charts/insight/templates/_helpers.tpl @@ -124,10 +124,12 @@ http://{{ .Values.airbyte.releaseName }}-airbyte-server-svc.{{ .Release.Namespac ============================================================================== App services are mandatory umbrella components — no deploy flag. */}} -{{- define "insight.apiGateway.host" -}}{{- printf "%s-api-gateway" .Release.Name -}}{{- end -}} +{{- define "insight.gateway.host" -}}{{- printf "%s-gateway" .Release.Name -}}{{- end -}} +{{- define "insight.authenticator.host" -}}{{- printf "%s-authenticator" .Release.Name -}}{{- end -}} {{- define "insight.analytics.host" -}}{{- printf "%s-analytics" .Release.Name -}}{{- end -}} {{- define "insight.identity.host" -}}{{- printf "%s-identity" .Release.Name -}}{{- end -}} {{- define "insight.frontend.host" -}}{{- printf "%s-frontend" .Release.Name -}}{{- end -}} +{{- define "insight.fakeidp.host" -}}{{- printf "%s-fakeidp" .Release.Name -}}{{- end -}} {{/* ============================================================================== @@ -156,37 +158,28 @@ Invoked from NOTES.txt so they fire on every install. {{- fail "credentials.deploymentMode=gitops is incompatible with credentials.autoGenerate=true. ArgoCD renders via `helm template` where `lookup` returns nil — auto-gen would rotate every DB password on each sync. Set credentials.autoGenerate: false and pre-create `insight-db-creds` (ExternalSecrets / sealed-secrets / SOPS)." -}} {{- end -}} - {{- /* OIDC: when auth is enabled, require either existingSecret or ALL - four inline fields. Defensive `default dict` guards against - aggressive override files that remove the whole apiGateway / - apiGateway.oidc block — without these, a nil-map dereference - would replace the fail message with a cryptic template error. - - NB: `clientSecret` is intentionally NOT validated. The api-gateway - uses Authorization Code + PKCE (public client flow) — `client_secret` - has no meaning in this architecture. Operators with a Confidential - IdP app should reconfigure it as Public/SPA-with-PKCE. */ -}} - {{- $gw := default dict .Values.apiGateway -}} - {{- $oid := default dict $gw.oidc -}} - {{- if not $gw.authDisabled -}} - {{- if not $oid.existingSecret -}} - {{- if or (not $oid.issuer) (not $oid.audience) (not $oid.clientId) (not $oid.redirectUri) -}} - {{- fail "apiGateway.oidc: when existingSecret is empty and authDisabled=false, ALL of issuer + audience + clientId + redirectUri are required" -}} - {{- end -}} - {{- end -}} + {{- /* Auth is ALWAYS on (NGINX_BFF EPIC #1583 — no auth_disabled path). + Every request enters through the nginx `gateway`, which runs + auth_request against the `authenticator`; the authenticator's OIDC + upstream + browser callback are REQUIRED. Defensive `default dict` + guards against override files that strip the whole authenticator + block (a nil-map deref would mask the fail with a cryptic error). + The leaf values are also `required` in templates/secrets.yaml; this + is the earlier, friendlier message. */ -}} + {{- $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 -}} - {{- /* frontend.devUserEmail is the dev-impersonation escape hatch — the - FE entrypoint stamps it into `oidc-config.js` so the browser - builds an unsigned-JWT bearer on every /api/* call. Only - meaningful when the gateway lets unauthenticated requests - through (apiGateway.authDisabled=true). Setting it together - with real auth is a misconfiguration — a forgotten value in a - prod overlay would silently impersonate every visitor as that - address. Catch it loudly at template time. */ -}} - {{- $fe := default dict .Values.frontend -}} - {{- if and $fe.devUserEmail (not $gw.authDisabled) -}} - {{- fail (printf "frontend.devUserEmail (%q) is only valid when apiGateway.authDisabled=true. Either clear devUserEmail (real OIDC flow) or set apiGateway.authDisabled=true (sandbox dev impersonation)." $fe.devUserEmail) -}} + {{- /* 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 -}} {{- end -}} {{- /* External hosts (L2 infra is out-of-chart → consumer must supply diff --git a/charts/insight/templates/platform-config.yaml b/charts/insight/templates/platform-config.yaml index 364cbcb9d..6dc9db576 100644 --- a/charts/insight/templates/platform-config.yaml +++ b/charts/insight/templates/platform-config.yaml @@ -55,7 +55,8 @@ data: AIRBYTE_JWT_SECRET_KEY: {{ .Values.airbyte.jwtSecret.key | quote }} # ─── App service hosts (for sidecars and debugging) ─── - INSIGHT_API_GATEWAY_HOST: {{ include "insight.apiGateway.host" . | quote }} + INSIGHT_GATEWAY_HOST: {{ include "insight.gateway.host" . | quote }} + INSIGHT_AUTHENTICATOR_HOST: {{ include "insight.authenticator.host" . | quote }} INSIGHT_ANALYTICS_HOST: {{ include "insight.analytics.host" . | quote }} INSIGHT_IDENTITY_HOST: {{ include "insight.identity.host" . | quote }} INSIGHT_FRONTEND_HOST: {{ include "insight.frontend.host" . | quote }} diff --git a/charts/insight/templates/secrets.yaml b/charts/insight/templates/secrets.yaml index d6c5d657f..bd2ca6b70 100644 --- a/charts/insight/templates/secrets.yaml +++ b/charts/insight/templates/secrets.yaml @@ -176,6 +176,36 @@ stringData: APP__gears__analytics__config__metric_catalog__tenant_default_id: {{ . | quote }} {{- end }} +--- +# Authenticator leaf config (NGINX_BFF). The gears-rust host reads these +# 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 +# local). The signing keys are a SEPARATE mounted Secret (not here). +apiVersion: v1 +kind: Secret +metadata: + name: insight-authenticator-config + labels: + {{- include "insight.labels" . | nindent 4 }} +type: Opaque +stringData: + APP__gears__authenticator__config__redis_url: {{ $redisUrl | quote }} + APP__gears__authenticator__config__identity_url: {{ printf "http://%s:8082" (include "insight.identity.host" .) | quote }} + # 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 + # 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 }} + APP__gears__authenticator__config__idp__client_secret: {{ .Values.authenticator.oidc.clientSecret | default "" | quote }} + # Browser-facing callback the IdP redirects back to (through the gateway edge). + APP__gears__authenticator__config__redirect_uri: {{ tpl (required "authenticator.oidc.redirectUri is required" .Values.authenticator.oidc.redirectUri) . | quote }} + # Service-token endpoint audience callers sign assertions for. + APP__gears__authenticator__config__service_tokens__audience: {{ printf "http://%s-authenticator.%s.svc.cluster.local:8093/internal/token" .Release.Name .Release.Namespace | quote }} + {{- if .Values.identity.deploy }} --- apiVersion: v1 @@ -208,6 +238,13 @@ stringData: (include "insight.mariadb.port" .) (required "identity.databaseName is required" .Values.identity.databaseName) | quote }} + # Gateway-JWT verification (NGINX_BFF R1). issuer = the authn-tls discovery + # FQDN (equals the token `iss`); JWKS is fetched over PLAIN http from the + # authenticator's main port (identity validates `iss` as a string and sets + # RequireHttpsMetadata=false, so no CA trust is needed — unlike the Rust + # plugin's https-only discovery). + IDENTITY__identity__auth_gateway_issuer: {{ printf "https://%s-authenticator.%s.svc.cluster.local:8443" .Release.Name .Release.Namespace | quote }} + IDENTITY__identity__auth_gateway_jwks_url: {{ printf "http://%s-authenticator.%s.svc.cluster.local:8083/.well-known/jwks.json" .Release.Name .Release.Namespace | quote }} {{- with (default "" (default dict .Values.global).tenantDefaultId) }} # Sourced from `global.tenantDefaultId` — see the secrets.yaml comment on # APP__gears__analytics__config__metric_catalog__tenant_default_id diff --git a/charts/insight/values.yaml b/charts/insight/values.yaml index ea43dd4de..35efb814f 100644 --- a/charts/insight/values.yaml +++ b/charts/insight/values.yaml @@ -309,51 +309,74 @@ redpanda: # NO defaults for image.tag, OIDC audience, OIDC issuer/clientId/redirectUri, # or downstream service URLs (those come from `{release}-platform` # ConfigMap via envFrom). The chart MUST refuse to render without them. -apiGateway: +# ─── Gateway (nginx+auth edge, NGINX_BFF EPIC #1583) ─────────────────────── +# The single entrance: OpenResty runs auth_request -> authenticator, injects the +# ES256 gateway JWT, and fans out to the app services + SPA. The subchart's own +# defaults template the upstreams (authenticatorUrl / frontUrl / analytics / +# identity) off `.Release.Name`, so no per-URL wiring is needed here. +gateway: replicaCount: 2 image: - repository: ghcr.io/constructorfabric/insight-api-gateway + repository: ghcr.io/constructorfabric/insight-gateway tag: "" # MUST be set (e.g. "0.1.0") pullPolicy: IfNotPresent resources: - requests: {cpu: 100m, memory: 128Mi} - limits: {cpu: 500m, memory: 256Mi} + requests: {cpu: 25m, memory: 32Mi} + limits: {cpu: 500m, memory: 128Mi} + # The gateway is the cluster ingress target; it routes "/" internally. ingress: - enabled: false + enabled: true className: nginx host: "" tls: enabled: false secretName: "" - # OIDC (required in prod — either existingSecret, or ALL four inline - # fields: issuer + audience + clientId + redirectUri). The umbrella - # validator fails when authDisabled=false and OIDC is incomplete. +# ─── Authenticator (OIDC login + Redis sessions + gateway-JWT mint) ───────── +authenticator: + replicaCount: 1 + image: + repository: ghcr.io/constructorfabric/insight-authenticator + tag: "" # MUST be set (e.g. "0.1.0") + pullPolicy: IfNotPresent + resources: + requests: {cpu: 50m, memory: 64Mi} + limits: {cpu: 500m, memory: 256Mi} + # Leaf config (redis/identity/gateway_issuer/idp/redirect) is composed by the + # umbrella into `insight-authenticator-config` (templates/secrets.yaml). + existingSecret: "insight-authenticator-config" + # ES256 signing keys (current.pem PKCS#8). NOT auto-generated — supplied as a + # sealed/BYO Secret with key `current.pem` (local: sealed under + # environments/local/sealed-secrets/insight/). + signingKeysSecret: "insight-authenticator-signing-keys" + # authn-tls sidecar so downstream verifiers resolve the JWKS over https. The + # cert is issued by cert-manager; `issuerRef.name` is the cluster's CA issuer + # (local: the self-signed `local-ca` ClusterIssuer from bootstrap). + tlsDiscovery: + enabled: true + port: 8443 + issuerRef: + 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. oidc: - existingSecret: "" - issuer: "" - audience: "" # MUST be set (e.g. "api://insight") - clientId: "" - redirectUri: "" - # Space-separated OIDC scopes; IdP-specific (Entra wants - # api:///Access.Default; Okta uses bare names). - scopes: "" - authDisabled: false # true = disable auth (LOCAL DEV ONLY) - gateway: - prefixPath: "/api" - corsEnabled: true - enableDocs: false - rateLimit: {rps: 100, burst: 50, inFlight: 32} - proxy: - routes: - - prefix: "/analytics" - # Templated — `tpl` evaluates this at install time so `.Release.Name` - # resolves to the umbrella's release name. Override with a literal - # URL (no template syntax) to point at an external service. - upstream: 'http://{{ .Release.Name }}-analytics:8081' - public: false - - prefix: "/identity" - upstream: 'http://{{ .Release.Name }}-identity:8082' - public: false + issuerUrl: "" # the IdP issuer (local: the in-cluster fakeidp FQDN) + clientId: "insight-authenticator" + clientSecret: "" + redirectUri: "" # browser-facing callback, through the gateway edge +# ─── 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" # ─── Analytics ──────────────────────────────────────────────────────────── analytics: replicaCount: 2 @@ -374,6 +397,17 @@ analytics: # default). The session-bound tenant from the gateway ALWAYS wins; this # global default fires only on header-less callers. Empty global value = # strict mode (400 tenant_unresolved on header-less requests). + # Gateway-JWT verification (NGINX_BFF R1). The oidc-authn-plugin resolves the + # authenticator's JWKS via OIDC discovery over https on `issuer` (the authn-tls + # FQDN, rendered with `tpl`). It trusts the issuer's CA (ca.crt) from the + # authn-tls cert Secret, mounted via `caCertSecret`. `issuer` here MUST equal + # the authenticator's gateway_issuer and the identity issuer. + gateway: + issuer: 'https://{{ .Release.Name }}-authenticator.{{ .Release.Namespace }}.svc.cluster.local:8443' + audience: "internal-services" + caCertSecret: "insight-authenticator-authn-tls-cert" + caCertPaths: + - /etc/insight/authn-ca/ca.crt # ─── Identity (.NET 9) ─────────────────────────────────────────────────── # The persons-store API. Reads from MariaDB `persons` written by the seed # pipeline (src/backend/services/identity/seed/seed-persons-from-identity-input.py) @@ -422,6 +456,10 @@ identity: existingSecret: "insight-identity-config" # ─── Frontend (SPA) ────────────────────────────────────────────────────── frontend: + # Served through the gateway edge at "/". Set deploy=false for a backend-only + # bring-up (the gateway resolves its front upstream lazily, so /api/* still + # works without a frontend pod). + deploy: true replicaCount: 1 image: repository: ghcr.io/constructorfabric/insight-front diff --git a/deploy/compose/analytics-fullauth.yaml b/deploy/compose/analytics-fullauth.yaml new file mode 100644 index 000000000..bce40c1e8 --- /dev/null +++ b/deploy/compose/analytics-fullauth.yaml @@ -0,0 +1,99 @@ +# dev-compose full-auth analytics host config (bind-mounted over +# /app/config/insight.yaml). Same structure as +# services/analytics/config/insight.yaml, but with the oidc-authn-plugin wired +# to the dev TLS discovery front (`authn-tls`) + its self-signed CA at +# /certs/ca.pem. DB/ClickHouse/identity/redis leaves come from APP__ env in +# docker-compose.yml. NGINX_BFF R1: analytics verifies the ES256 gateway JWT. + +server: + # MUST stay OUTSIDE /app: compose auto-reload (watchexec) watches /app (the + # binary's dir), so a home_dir under /app makes the gear's own startup writes + # (gts store, etc.) trip a restart mid-migration → a concurrent-migrator + # crash-loop. Keep runtime state in /tmp (ephemeral is fine for dev). + home_dir: "/tmp/analytics" + +logging: + default: + console_level: info + +gears: + api-gateway: + config: + bind_addr: "0.0.0.0:8081" + enable_docs: true + cors_enabled: true + openapi: + title: "Insight Analytics API" + version: "0.1.0" + description: "Read-only query service over predefined ClickHouse metrics" + auth_disabled: false + + gear-orchestrator: + config: {} + + grpc-hub: + config: + listen_addr: "uds:///tmp/analytics-grpc" + + authn-resolver: + config: + vendor: "hyperspot" + + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + trusted_issuers: + # = token `iss` (authenticator gateway_issuer). discovery_url omitted + # → {issuer}/.well-known/openid-configuration, served by authn-tls. + - issuer: "https://authn-tls:8443" + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + custom_ca_certificate_paths: ["/certs/ca.pem"] + s2s_oauth: + discovery_url: "https://authn-tls:8443" + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + + authz-resolver: + config: + vendor: "hyperspot" + + static-authz-plugin: + config: + vendor: "hyperspot" + priority: 100 + + tenant-resolver: + config: + vendor: "hyperspot" + + single-tenant-tr-plugin: + config: + vendor: "hyperspot" + priority: 20 + + analytics: + config: + bind_addr: "0.0.0.0:8081" + database_url: "" + clickhouse_url: "" + clickhouse_database: "insight" + identity_url: "" + redis_url: "" + metric_catalog: + tenant_default_id: ~ diff --git a/deploy/compose/authn-tls.conf b/deploy/compose/authn-tls.conf new file mode 100644 index 000000000..268be58b0 --- /dev/null +++ b/deploy/compose/authn-tls.conf @@ -0,0 +1,31 @@ +# TLS discovery front for the authenticator (dev-compose full-auth). +# +# The analytics oidc-authn-plugin resolves the authenticator's JWKS via OIDC +# discovery and its runtime is https-only. This nginx terminates TLS with a +# self-signed cert (CN/SAN authn-tls, trusted by analytics via /certs/ca.pem) +# and proxies the well-known endpoints to the authenticator's plain-http +# listener. `iss` = https://authn-tls:8443, so the discovery doc + JWKS are +# served from this origin. Certs are generated by dev-compose.sh into +# deploy/compose/authn-tls-certs/. +# Docker's embedded DNS (127.0.0.11) + a variable proxy_pass so the +# authenticator upstream is resolved per-request, not at config load — the front +# survives an authenticator restart (stale-IP-proof) without needing a restart. +resolver 127.0.0.11 ipv6=off valid=10s; + +server { + listen 8443 ssl; + server_name authn-tls; + + ssl_certificate /certs/server.pem; + ssl_certificate_key /certs/server.key; + + location /.well-known/ { + set $authn_upstream http://authenticator:8083; + proxy_pass $authn_upstream; + proxy_set_header Host $host; + } + + location = /healthz { + return 200 "ok\n"; + } +} diff --git a/deploy/compose/front-ghcr-patch-template.sh b/deploy/compose/front-ghcr-patch-template.sh index c2744b751..d602e938a 100755 --- a/deploy/compose/front-ghcr-patch-template.sh +++ b/deploy/compose/front-ghcr-patch-template.sh @@ -2,7 +2,7 @@ # Compose-only patch for the insight-front ghcr image. # # The published image's nginx template deliberately has no /api proxy — -# in k8s the cluster ingress routes /api/* straight to the api-gateway +# in k8s the cluster ingress routes /api/* straight to the gateway # pod, so the FE pod never sees those requests. The docker-compose dev # stack has nothing in front of the FE pod, so /api needs to land # locally. @@ -35,16 +35,16 @@ else print print "" print " # Compose-only /api proxy injected by front-ghcr-patch-template.sh." - print " # k8s relies on the cluster ingress to route /api → api-gateway;" + print " # k8s relies on the cluster ingress to route /api → gateway;" print " # compose has no front-proxy so we add the hop here." print " #" print " # 127.0.0.11 is Dockers embedded DNS. We use it via `resolver` +" print " # `set $upstream_apigw` so nginx resolves at request time instead" print " # of startup — without this, the FE container refuses to start" - print " # if api-gateway isnt yet reachable, breaking `up -d` ordering." + print " # if gateway isnt yet reachable, breaking `up -d` ordering." print " resolver 127.0.0.11 valid=10s;" print " location /api/ {" - print " set $upstream_apigw \"api-gateway:8080\";" + print " set $upstream_apigw \"gateway:8080\";" print " proxy_pass http://$upstream_apigw;" print " proxy_http_version 1.1;" print " proxy_set_header Host $host;" @@ -58,7 +58,7 @@ else { print } ' "$TPL" > "$TPL.new" mv "$TPL.new" "$TPL" - echo "front-ghcr-patch: inserted /api → api-gateway:8080 into template." + echo "front-ghcr-patch: inserted /api → gateway:8080 into template." fi exec /docker-entrypoint.sh "$@" diff --git a/deploy/compose/gateway/routes.yaml b/deploy/compose/gateway/routes.yaml index 6a7ec65e9..7680d9bbf 100644 --- a/deploy/compose/gateway/routes.yaml +++ b/deploy/compose/gateway/routes.yaml @@ -9,8 +9,12 @@ defaults: - X-Real-IP - Forwarded routes: - - prefix: /api/v1/analytics + # strip_prefix turns `/api/analytics/v1/metrics` into the service's own + # `/v1/metrics` (analytics + identity are addressed directly, serving `/v1/*`). + - prefix: /api/analytics upstream: http://analytics:8081 + strip_prefix: true timeout_ms: 60000 - - prefix: /api/v1/identity + - prefix: /api/identity upstream: http://identity:8082 + strip_prefix: true diff --git a/deploy/compose/insight-init.sh b/deploy/compose/insight-init.sh index 03e59518f..97ef2bbf9 100755 --- a/deploy/compose/insight-init.sh +++ b/deploy/compose/insight-init.sh @@ -779,7 +779,9 @@ EOF # holds the committed sandbox config. cp "$values_tmpl" "$values_out" yq -i ".global.tenantDefaultId = \"$TENANT_DEFAULT_ID\"" "$values_out" - yq -i ".frontend.devUserEmail = \"$DEV_USER_EMAIL\"" "$values_out" + # Full auth: the dev login identity is the fakeidp default user (must exist in + # identity's `persons`), not a frontend impersonation escape hatch. + yq -i ".fakeidp.devUserEmail = \"$DEV_USER_EMAIL\"" "$values_out" echo "Wrote $values_out." >&2 cat >&2 < ENV=local`. ## L3 umbrella (this overlay) runs the app services only into `insight`. ## -## OIDC is disabled for sandbox; the apiGateway lets unauthenticated -## requests through. Bring up the rest of the stack and then decide on -## an IdP (Okta, Entra, Auth0, Keycloak, stub Dex, …). -## # Gitops contract: `insight-db-creds` is pre-sealed in the `insight` # namespace, fetched by `scripts/secret-fetch.sh` with resource name @@ -82,31 +78,74 @@ ingestion: # ─── App services ────────────────────────────────────────────────────────── # -# Image tags are NOT pinned here. The umbrella chart's appVersion (and -# each subchart's image.tag default) drives which image versions get -# deployed — bumping `.insight-version` switches to that chart's image -# defaults atomically. Override `image.tag` per service ONLY when you -# need to roll out a hotfix on one service ahead of a new chart version; -# remove the override on the next chart bump so you don't drift from -# the chart's tested set. - -apiGateway: +# LOCAL IMAGES: the sandbox runs locally-built `:dev` images, not ghcr. OrbStack +# (and k3d/kind with a loaded image) share the Docker image store with the +# cluster, so `IfNotPresent` finds them without a registry. Build them first: +# ./dev-compose.sh build # builds all backend service images :dev + +# 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. +gateway: + image: { repository: insight-gateway, tag: dev, pullPolicy: IfNotPresent } replicaCount: 1 - # Sandbox: no OIDC. Real envs flip this to false and supply an - # `insight-oidc` Secret in the `insight` namespace. - authDisabled: true - # Ingress: empty host matches any Host header — on OrbStack/k3d/kind - # the ingress-nginx LoadBalancer takes a host-reachable IP (see - # `kubectl -n ingress-nginx get svc`) so `http:///api/...` - # works without /etc/hosts entries. Real envs set `host:` to an FQDN - # and add TLS via cert-manager. + # The gateway is the cluster ingress target. Empty host matches any Host + # header — on OrbStack/k3d/kind the ingress-nginx LoadBalancer takes a + # host-reachable IP (`kubectl -n ingress-nginx get svc`), so + # `http:///...` works without /etc/hosts entries. Real envs set a + # host + TLS via cert-manager. ingress: enabled: true resources: - requests: { cpu: 50m, memory: 128Mi } - limits: { cpu: 500m, memory: 512Mi } + requests: { cpu: 25m, memory: 32Mi } + limits: { cpu: 500m, memory: 128Mi } + +authenticator: + image: { repository: insight-authenticator, tag: dev, pullPolicy: IfNotPresent } + replicaCount: 1 + # authn-tls sidecar cert is issued by the bootstrap `local-ca` ClusterIssuer. + tlsDiscovery: + enabled: true + issuerRef: + name: local-ca + kind: ClusterIssuer + # Local IdP = the in-cluster fakeidp (below), exposed via the ingress at /idp. + # Both the browser AND the authenticator pod reach fakeidp at the SAME ingress + # LoadBalancer IP (`__INGRESS_LB_IP__`) so the id_token `iss` matches for + # discovery AND for the browser redirect to /authorize. `redirectUri` stays on + # `http://localhost` because the `__Host-sid` session cookie requires a + # secure-context origin, and `http://localhost` qualifies (the LB IP does not). + oidc: + issuerUrl: 'http://__INGRESS_LB_IP__/idp' + clientId: "insight-authenticator" + redirectUri: "http://localhost/auth/callback" + +# ─── Local IdP topology ────────────────────────────────────────────────────── +# Local k8s runs the in-cluster `fakeidp` exposed THROUGH the ingress at the +# `/idp` prefix (an nginx rewrite strips the prefix — fakeidp serves OIDC at root +# and emits absolute URLs). The browser reaches it at `http://__INGRESS_LB_IP__/idp` +# and so does the authenticator pod (via the same LoadBalancer IP), so the +# id_token issuer matches in both directions. The OIDC callback lands on +# `http://localhost/auth/callback` — a secure context — so the `__Host-sid` +# cookie is accepted. `__INGRESS_LB_IP__` is the ingress-nginx LoadBalancer IP +# (`kubectl -n ingress-nginx get svc ingress-nginx-controller`); the +# per-developer gitignored `values.yaml` must substitute the concrete IP. + +# fakeidp — DEV/E2E-only OIDC provider. NEVER enable in a real environment. +fakeidp: + image: { repository: insight-fakeidp, tag: dev, pullPolicy: IfNotPresent } + deploy: true + # Exposed via the ingress at /idp so the browser can reach /authorize. + ingress: + enabled: true + issuer: 'http://__INGRESS_LB_IP__/idp' + # Default login identity — must exist in identity's `persons` (seed first). + # The wizard rewrites this to the collected email. + devUserEmail: "dev@company.nonpresent" analytics: + image: { repository: insight-analytics, tag: dev, pullPolicy: IfNotPresent } replicaCount: 1 resources: requests: { cpu: 50m, memory: 128Mi } @@ -122,6 +161,7 @@ analytics: # tenant_default_id) is produced by scripts/compose-app-secrets.sh # from insight-db-creds. identity: + image: { repository: insight-identity, tag: dev, pullPolicy: IfNotPresent } deploy: true databaseName: "identity" # Optional. When set (must be a valid GUID), identity-resolution @@ -135,15 +175,12 @@ identity: limits: { cpu: 500m, memory: 512Mi } frontend: + # The SPA image is pulled from ghcr (ghcr.io/constructorfabric/insight-front, + # chart appVersion tag) — no local build needed. It deploys but may not fully + # work against the current backend yet. + deploy: true replicaCount: 1 - # Ingress: same host as apiGateway. Path-based routing in - # ingress-nginx — `/api/*` → api-gateway, `/*` → frontend. Empty host - # matches any Host header (see apiGateway.ingress comment). + # No own ingress: the SPA is served THROUGH the gateway edge (the gateway + # proxies "/" to the frontend), so only the gateway holds an Ingress. ingress: - enabled: true - # Dev-impersonation: matches apiGateway.authDisabled=true above. The - # FE entrypoint writes a runtime `oidc-config.js` so the browser - # builds an unsigned-JWT bearer for this address. Wizard collects the - # email and the operator can edit `values.yaml` (gitignored copy) - # afterwards. Real envs leave this empty and configure `oidc.*`. - devUserEmail: "dev@company.nonpresent" + enabled: false diff --git a/deploy/gitops/scripts/compose-app-secrets.sh b/deploy/gitops/scripts/compose-app-secrets.sh index a8dc84c5f..c6ef05ba6 100755 --- a/deploy/gitops/scripts/compose-app-secrets.sh +++ b/deploy/gitops/scripts/compose-app-secrets.sh @@ -67,6 +67,31 @@ IDENTITY_DB=$(yq -r '.identity.databaseName // "identity"' "$VALUES") TENANT_DEFAULT=$(yq -r '.global.tenantDefaultId // ""' "$VALUES") IDENTITY_ORG_CHART_SOURCE=$(yq -r '.identity.orgChartSourceType // ""' "$VALUES") +# ── Authenticator OIDC (NGINX_BFF). issuerUrl/redirectUri may be Helm template +# strings in values.yaml; render {{ .Release.Name }}/{{ .Release.Namespace }} +# the same way the chart's `tpl` would. ── +render_tpl() { + # shellcheck disable=SC2001 + echo "$1" \ + | sed "s/{{[[:space:]]*\.Release\.Name[[:space:]]*}}/${RELEASE}/g" \ + | sed "s/{{[[:space:]]*\.Release\.Namespace[[:space:]]*}}/${NS_APP}/g" +} +AUTH_IDP_ISSUER=$(render_tpl "$(yq -r '.authenticator.oidc.issuerUrl // ""' "$VALUES")") +AUTH_CLIENT_ID=$( yq -r '.authenticator.oidc.clientId // "insight-authenticator"' "$VALUES") +AUTH_CLIENT_SECRET=$( yq -r '.authenticator.oidc.clientSecret // ""' "$VALUES") +AUTH_REDIRECT_URI=$(render_tpl "$(yq -r '.authenticator.oidc.redirectUri // ""' "$VALUES")") +# The authn-tls discovery FQDN — the minted token `iss` and downstream issuer. +GATEWAY_ISSUER="https://${RELEASE}-authenticator.${NS_APP}.svc.cluster.local:8443" +GATEWAY_JWKS_URL="http://${RELEASE}-authenticator.${NS_APP}.svc.cluster.local:8083/.well-known/jwks.json" +AUTH_TOKEN_AUD="http://${RELEASE}-authenticator.${NS_APP}.svc.cluster.local:8093/internal/token" + +for v in AUTH_IDP_ISSUER AUTH_REDIRECT_URI; do + [ -n "${!v}" ] && [ "${!v}" != "null" ] || { + echo "ERROR: authenticator.oidc.* incomplete in $VALUES ($v empty) — auth is always on (NGINX_BFF)" >&2 + exit 1 + } +done + for v in MDB_HOST MDB_USER MDB_DB CH_HOST CH_USER CH_DB RD_HOST; do [ -n "${!v}" ] && [ "${!v}" != "null" ] || { echo "ERROR: $v not set in $VALUES" >&2 @@ -148,6 +173,34 @@ EOF } | kubectl -n "$NS_APP" apply -f - >/dev/null echo "composed → $NS_APP/insight-analytics-config" +# `insight-authenticator-config` (NGINX_BFF): the authenticator's leaf config. +# The chart emits this only when autoGenerate=true; in gitops mode we compose it +# here. redis reuses insight-db-creds; gateway_issuer is the authn-tls FQDN; the +# idp.* + redirect come from authenticator.oidc.* in values.yaml. The signing +# keys are a SEPARATE sealed Secret (insight-authenticator-signing-keys). +{ + cat </dev/null +echo "composed → $NS_APP/insight-authenticator-config" + # `insight-identity-config` carries the .NET identity service's # IDENTITY__* config. The service applies its own DbUp migrations # against `${IDENTITY_DB}` at startup — see ADR-0006 (service-owned @@ -166,6 +219,11 @@ metadata: type: Opaque stringData: IDENTITY__mariadb__url: "mysql://${MDB_USER}:${MDB_PW}@${MDB_HOST}:${MDB_PORT}/${IDENTITY_DB}" + # Gateway-JWT verification (NGINX_BFF R1). issuer = the authn-tls FQDN (equals + # the token iss); JWKS is fetched over plain http from the authenticator main + # port (identity validates iss as a string, RequireHttpsMetadata=false). + IDENTITY__identity__auth_gateway_issuer: "${GATEWAY_ISSUER}" + IDENTITY__identity__auth_gateway_jwks_url: "${GATEWAY_JWKS_URL}" EOF if [ -n "$TENANT_DEFAULT" ] && [ "$TENANT_DEFAULT" != "null" ]; then echo " IDENTITY__identity__tenant_default_id: \"${TENANT_DEFAULT}\"" diff --git a/deploy/gitops/secrets-store.yaml.template b/deploy/gitops/secrets-store.yaml.template index 98970fafe..9bc0b3ace 100644 --- a/deploy/gitops/secrets-store.yaml.template +++ b/deploy/gitops/secrets-store.yaml.template @@ -67,9 +67,28 @@ insight-local-insight-db-creds: | clickhouse-password: "REPLACE_ADMIN_PASSWORD" redis-password: "REPLACE_REDIS_PASSWORD" -## insight-oidc — required only when apiGateway.authDisabled: false (i.e. -## when an external IdP is configured). Skip for the local sandbox (the -## shipped local overlay sets authDisabled: true). +## insight-authenticator-signing-keys — the ES256 gateway-JWT signing key the +## authenticator mounts at signing_keys_path. Generate a throwaway P-256 key: +## openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \ +## -pkeyopt ec_param_enc:named_curve -out current.pem +## then paste it under stringData.current.pem (indented, block scalar). +insight-local-insight-authenticator-signing-keys: | + apiVersion: v1 + kind: Secret + metadata: + name: insight-authenticator-signing-keys + namespace: insight + type: Opaque + stringData: + current.pem: | + -----BEGIN PRIVATE KEY----- + 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). ## ## 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 ee9098fd3..29ff5ba51 100755 --- a/dev-compose.sh +++ b/dev-compose.sh @@ -100,7 +100,7 @@ services to ghcr images, then `docker compose up -d`. Options: --from-ghcr=svc1,svc2 Pull these backend services from ghcr instead - of building. Recognised: api-gateway, + of building. Recognised: analytics, identity. --build-only=svc1,svc2 Build only these; everything else from ghcr. --frontend-mode=MODE Override FRONTEND_MODE for this run. @@ -119,7 +119,7 @@ EOF } # Generate the dev-only ES256 signing key the authenticator mounts at -# signing_keys_path. Never committed (gitignored) and never baked into an +# signing_keys_path (§9.6). Never committed (gitignored) and never baked into an # image; regenerated on demand. Prod mounts a real key via a K8s Secret. ensure_authenticator_dev_key() { local dir="deploy/compose/authenticator-dev-keys" @@ -164,6 +164,75 @@ ensure_service_token_dev_key() { chmod 600 "$key" } +# Generate the dev-only self-signed TLS cert for the `authn-tls` front (SAN +# authn-tls). The analytics oidc-authn-plugin resolves the authenticator's JWKS +# via OIDC discovery over https ONLY; authn-tls terminates that TLS and analytics +# trusts ca.pem. Never committed (gitignored). Regenerated when missing/expired. +ensure_authn_tls_certs() { + local dir="deploy/compose/authn-tls-certs" + local cert="$dir/server.pem" + [[ -f "$cert" ]] && openssl x509 -in "$cert" -noout -checkend 86400 2>/dev/null && return 0 + mkdir -p "$dir" + echo "=== Generating dev TLS cert for the authn-tls discovery front ($cert) ===" + # A config file (not -addext) keeps this working on LibreSSL (macOS default). + local cnf="$dir/openssl.cnf" + cat > "$cnf" <<'EOF' +[req] +distinguished_name = dn +x509_extensions = v3 +prompt = no +[dn] +CN = authn-tls +[v3] +subjectAltName = DNS:authn-tls +EOF + if ! openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -pkeyopt ec_param_enc:named_curve -out "$dir/server.key" 2>/dev/null; then + echo "WARN: openssl unavailable — analytics cannot verify the gateway JWT without the authn-tls cert" >&2 + return 1 + fi + openssl req -x509 -key "$dir/server.key" -out "$cert" -days 3650 -config "$cnf" 2>/dev/null + # The self-signed leaf is its own trust root (analytics adds it as a CA). + cp "$cert" "$dir/ca.pem" + chmod 644 "$dir/server.key" "$cert" "$dir/ca.pem" +} + +# The host's primary IPv4 (macOS: the default-route interface; Linux: the src of +# the default route). An IP LITERAL — browsers don't HTTPS-upgrade it (unlike a +# hostname), and it's reachable from both the host browser and the containers. +detect_host_ip() { + if command -v ipconfig >/dev/null 2>&1; then # macOS + local ifc + ifc="$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')" + if [[ -n "$ifc" ]] && ipconfig getifaddr "$ifc" 2>/dev/null; then return 0; fi + ipconfig getifaddr en0 2>/dev/null && return 0 + return 1 + fi + ip route get 1.1.1.1 2>/dev/null \ + | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}' +} + +# Point fakeidp's issuer at the host IP so the BROWSER login flow works out of +# the box. The authenticator 302s the browser to `{issuer}/authorize`; a +# hostname (`fakeidp:8084`) gets HTTPS-upgraded by the browser and fails (fakeidp +# is http-only), and `localhost` means the container itself. The host IP +# satisfies both sides over plain http. fakeidp's advertised issuer and the +# authenticator's expected issuer MUST match, so set both. Skipped when the +# operator pinned an issuer (a real IdP) or when no host IP is detectable +# (offline) — then it stays `fakeidp:8084`, which still serves the curl/e2e path. +ensure_fakeidp_issuer() { + [[ -n "${AUTHENTICATOR_OIDC_ISSUER:-}" ]] && return 0 + local ip + ip="$(detect_host_ip)" + if [[ -z "$ip" ]]; then + echo "WARN: no host IP detected — fakeidp issuer stays http://fakeidp:8084." >&2 + echo " curl/e2e still work; browser login needs the browser's HTTPS-upgrade off." >&2 + return 0 + fi + export FAKEIDP_ISSUER="http://$ip:8084" + export AUTHENTICATOR_OIDC_ISSUER="http://$ip:8084" + echo "fakeidp issuer → http://$ip:8084 (host IP; browser-reachable, no HTTPS upgrade)" +} + cmd_up() { local env_file=".env.compose" local from_ghcr_csv="" @@ -206,15 +275,19 @@ cmd_up() { env_file="$(resolve_env_file "$env_file")" set -a; source "$env_file"; set +a + # Browser OIDC: default the fakeidp issuer to the host IP (unless pinned). + ensure_fakeidp_issuer + [[ -n "$frontend_mode_override" ]] && FRONTEND_MODE="$frontend_mode_override" FRONTEND_MODE="${FRONTEND_MODE:-dev}" # ── Resolve which services go to ghcr ──────────────────────────── - local all_backend="api-gateway analytics identity" + # The legacy Rust api-gateway is gone; the nginx `gateway` is the sole :8080 + # entry doing full auth via the authenticator (NGINX_BFF #1583 step 09). + local all_backend="analytics identity" local ghcr_list="" local build_list="" - [[ -n "${API_GATEWAY_IMAGE:-}" ]] && ghcr_list=$(add "$ghcr_list" api-gateway) [[ -n "${ANALYTICS_IMAGE:-}" ]] && ghcr_list=$(add "$ghcr_list" analytics) [[ -n "${IDENTITY_IMAGE:-}" ]] && ghcr_list=$(add "$ghcr_list" identity) @@ -234,7 +307,6 @@ cmd_up() { done fi - contains "$ghcr_list" api-gateway && [[ -z "${API_GATEWAY_IMAGE:-}" ]] && export API_GATEWAY_IMAGE="ghcr.io/constructorfabric/insight-api-gateway:${API_GATEWAY_GHCR_TAG:-latest}" contains "$ghcr_list" analytics && [[ -z "${ANALYTICS_IMAGE:-}" ]] && export ANALYTICS_IMAGE="ghcr.io/constructorfabric/insight-analytics:${ANALYTICS_GHCR_TAG:-latest}" contains "$ghcr_list" identity && [[ -z "${IDENTITY_IMAGE:-}" ]] && export IDENTITY_IMAGE="ghcr.io/constructorfabric/insight-identity:${IDENTITY_GHCR_TAG:-latest}" true @@ -270,8 +342,10 @@ YML fi } > "$override" - # Ensure the authenticator's dev signing key exists before bring-up. + # Ensure the authenticator's dev signing key + the authn-tls discovery cert + # exist before bring-up (full-auth: analytics verifies the gateway JWT). ensure_authenticator_dev_key + ensure_authn_tls_certs local compose_cmd=(docker compose --env-file "$env_file" -f docker-compose.yml -f "$override") local profiles=() @@ -294,7 +368,6 @@ YML # binary is bind-mounted as a file — omit it and compose auto-creates the # mount source as an empty directory, failing container init. local rust_bins="authenticator" - contains "$ghcr_list" api-gateway || rust_bins="$rust_bins insight-api-gateway" contains "$ghcr_list" analytics || rust_bins="$rust_bins analytics" rust_bins=$(trim "$rust_bins") if [[ -n "$rust_bins" ]]; then @@ -308,8 +381,7 @@ YML apt-get update && apt-get install -y --no-install-recommends \ protobuf-compiler libprotobuf-dev pkg-config libssl-dev > /dev/null cargo build --release$bin_flags - mkdir -p /out/api-gateway /out/analytics /out/authenticator - [ -f /target/release/insight-api-gateway ] && install -m 0755 /target/release/insight-api-gateway /out/api-gateway/insight-api-gateway || true + mkdir -p /out/analytics /out/authenticator [ -f /target/release/analytics ] && install -m 0755 /target/release/analytics /out/analytics/analytics || true [ -f /target/release/authenticator ] && install -m 0755 /target/release/authenticator /out/authenticator/authenticator || true " @@ -389,7 +461,7 @@ report_service_urls() { if [[ "$frontend_up" == "true" ]]; then printf ' %-18s %s\n' "Frontend UI" "http://$h:${FRONTEND_PORT:-3000}" fi - printf ' %-18s %s\n' "API Gateway" "http://$h:${API_GATEWAY_PORT:-8080}" + printf ' %-18s %s\n' "Gateway" "http://$h:${GATEWAY_PORT:-8080}" printf ' %-18s %s\n' "Analytics API" "http://$h:${ANALYTICS_PORT:-8081}" printf ' %-18s %s\n' "Identity API" "http://$h:${IDENTITY_PORT:-8082}" printf ' %-18s %s\n' "Authenticator" "http://$h:${AUTHENTICATOR_PORT:-8083}" @@ -487,7 +559,6 @@ Rebuild one host-side artefact and let the already-running container pick it up via ENABLE_AUTO_RELOAD. Targets: - api-gateway Rust gateway binary only. analytics Rust analytics binary only. authenticator Rust authenticator binary only. identity .NET 9 publish output. @@ -518,27 +589,31 @@ cmd_build() { apt-get update && apt-get install -y --no-install-recommends \ protobuf-compiler libprotobuf-dev pkg-config libssl-dev > /dev/null cargo build --release$bin_flags - mkdir -p /out/api-gateway /out/analytics /out/authenticator - [ -f /target/release/insight-api-gateway ] && install -m 0755 /target/release/insight-api-gateway /out/api-gateway/insight-api-gateway || true + mkdir -p /out/analytics /out/authenticator [ -f /target/release/analytics ] && install -m 0755 /target/release/analytics /out/analytics/analytics || true [ -f /target/release/authenticator ] && install -m 0755 /target/release/authenticator /out/authenticator/authenticator || true " } - case "$target" in - api-gateway) build_rust_bins insight-api-gateway ;; - analytics) build_rust_bins analytics ;; - authenticator) build_rust_bins authenticator ;; - rust) build_rust_bins insight-api-gateway analytics authenticator ;; - identity) "${compose_cmd[@]}" run --rm build-dotnet ;; - frontend) "${compose_cmd[@]}" run --rm build-frontend ;; - all) - build_rust_bins insight-api-gateway analytics authenticator - "${compose_cmd[@]}" run --rm build-dotnet - "${compose_cmd[@]}" run --rm build-frontend - ;; - *) echo "ERROR: unknown target: $target" >&2; cmd_build_help; return 2 ;; - esac + # Accept MULTIPLE targets, e.g. `build authenticator identity`. Rust bins are + # batched into one build; dotnet/frontend run once if requested. + local rust_bins="" want_dotnet=false want_frontend=false t + for t in "$@"; do + case "$t" in + analytics) rust_bins="$rust_bins analytics" ;; + authenticator) rust_bins="$rust_bins authenticator" ;; + rust) rust_bins="$rust_bins analytics authenticator" ;; + identity) want_dotnet=true ;; + frontend) want_frontend=true ;; + all) rust_bins="$rust_bins analytics authenticator"; want_dotnet=true; want_frontend=true ;; + *) echo "ERROR: unknown target: $t" >&2; cmd_build_help; return 2 ;; + esac + done + rust_bins="$(trim "$rust_bins")" + # shellcheck disable=SC2086 # word-split the bin list intentionally + [[ -n "$rust_bins" ]] && build_rust_bins $rust_bins + [[ "$want_dotnet" == true ]] && "${compose_cmd[@]}" run --rm build-dotnet + [[ "$want_frontend" == true ]] && "${compose_cmd[@]}" run --rm build-frontend echo "Done. If a runtime container has ENABLE_AUTO_RELOAD=true it will restart automatically." } diff --git a/docker-compose.yml b/docker-compose.yml index 8786e572f..5a77034e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -179,44 +179,8 @@ services: # ref in .env.compose — the dev-compose.sh up script generates an # override that drops `build:` and the bind-mount for affected services. - api-gateway: - !!merge <<: *backend-common - image: ${API_GATEWAY_IMAGE:-insight-api-gateway:dev} - container_name: insight-api-gateway - build: - context: src/backend - dockerfile: services/api-gateway/Dockerfile - depends_on: - # required: false → compose skips this dependency when mariadb's - # `local-mariadb` profile isn't active (external MariaDB mode). - mariadb: {condition: service_healthy, required: false} - identity: {condition: service_started} - environment: - !!merge <<: *backend-env - # Service config is baked at /app/config/. Compose `command:` below - # selects which one; see CONTRIBUTING.md for the OIDC swap. - volumes: - # Bind-mount the host-built Linux binary over the baked one. The - # ./deploy/compose/build/api-gateway path is populated by `./dev-compose.sh up` - # (or `./dev-compose.sh build api-gateway`), which runs the - # `build-rust` builder service under the `build` profile and writes - # the freshly-compiled binary to /out/api-gateway/. A plain - # `docker compose build` does NOT create this artefact — it only - # builds the runtime image; the bind-mount source has to exist on - # the host or compose errors out. - - ./deploy/compose/build/api-gateway/insight-api-gateway:/app/insight-api-gateway:ro - # Mount the service's config dir so YAML edits don't require a - # full image rebuild. - - ./src/backend/services/api-gateway/config:/app/config:ro - command: - - "/app/insight-api-gateway" # - - "--" - - "/app/insight-api-gateway" - - "-c" - - "/app/config/no-auth.yaml" - - "run" - ports: - - "${API_GATEWAY_PORT:-8080}:8080" + # (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.) analytics: !!merge <<: *backend-common @@ -234,7 +198,7 @@ services: environment: !!merge <<: *backend-env # analytics runs on the gears-rust host: structural config - # (gears list, api-gateway REST host w/ auth_disabled) lives in the + # (gears list, api-gateway REST host w/ auth ENABLED) lives in the # bind-mounted config/insight.yaml; these APP__gears__analytics__config__* # env vars (toolkit `Env::prefixed`, gear config key `analytics`) # override the deployment-specific leaf values. @@ -246,11 +210,19 @@ services: APP__gears__analytics__config__identity_url: "http://identity:8082" APP__gears__analytics__config__redis_url: "redis://redis:6379" APP__gears__analytics__config__metric_catalog__tenant_default_id: "${TENANT_DEFAULT_ID:-00000000-df51-5b42-9538-d2b56b7ee953}" + # Gateway-JWT verification (NGINX_BFF R1) is done by the upstream + # cf-gears-oidc-authn-plugin. Its config is nested/list-shaped (not + # env-injectable) and resolves the JWKS via OIDC discovery over HTTPS ONLY, + # so the concrete plugin config (issuer = the authn-tls front, + its CA at + # /certs/ca.pem) is bind-mounted from deploy/compose/analytics-fullauth.yaml + # over /app/config/insight.yaml below. The leaf values above still override. volumes: - ./deploy/compose/build/analytics/analytics:/app/analytics:ro - # Mount the gears config so YAML edits don't require an image rebuild - # (mirrors api-gateway). watchexec restarts the binary on change. - - ./src/backend/services/analytics/config:/app/config:ro + # Full-auth plugin config (issuer + CA) — bind-mounted over the committed + # placeholder config so analytics verifies real gateway JWTs in dev. + - ./deploy/compose/analytics-fullauth.yaml:/app/config/insight.yaml:ro + # Self-signed CA for the authn-tls discovery front. + - ./deploy/compose/authn-tls-certs:/certs:ro command: - "/app/analytics" # - "--" @@ -278,6 +250,14 @@ services: IDENTITY__mariadb__url: "mysql://${MARIADB_USER:-insight}:${MARIADB_PASSWORD:-insight-local}@${MARIADB_HOST:-mariadb}:${MARIADB_INTERNAL_PORT:-3306}/identity" IDENTITY__identity__bind_addr: "0.0.0.0:8082" IDENTITY__identity__tenant_default_id: "${TENANT_DEFAULT_ID:-00000000-df51-5b42-9538-d2b56b7ee953}" + # Gateway-JWT verification (NGINX_BFF R1). `auth_gateway_issuer` must equal + # the authenticator's gateway_issuer (the token `iss`); `auth_gateway_jwks_url` + # must be network-reachable (the authenticator serves JWKS on :8083). + # `iss` must equal the token's (authn-tls). identity is .NET (not the gears + # plugin) and fetches JWKS from an explicit URL with RequireHttps off, so + # it keeps the plain-http JWKS endpoint directly — no TLS front needed here. + IDENTITY__identity__auth_gateway_issuer: "${AUTHENTICATOR_GATEWAY_ISSUER:-https://authn-tls:8443}" + IDENTITY__identity__auth_gateway_jwks_url: "${GATEWAY_JWKS_URL:-http://authenticator:8083/.well-known/jwks.json}" volumes: # The .NET publish dir is replaced wholesale, not just one binary. - ./deploy/compose/build/identity:/app:ro @@ -317,13 +297,18 @@ services: APP__gears__authenticator__config__redis_url: "redis://redis:6379" APP__gears__authenticator__config__signing_keys_path: "/app/keys" APP__gears__authenticator__config__identity_url: "${AUTHENTICATOR_IDENTITY_URL:-http://identity:8082}" - APP__gears__authenticator__config__gateway_issuer: "${AUTHENTICATOR_GATEWAY_ISSUER:-http://localhost:8080}" + # Token `iss` = the TLS discovery front, so downstream verifiers resolve + # 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}" APP__gears__authenticator__config__idp__client_id: "${OIDC_CLIENT_ID:-insight-authenticator}" APP__gears__authenticator__config__idp__client_secret: "${OIDC_CLIENT_SECRET:-}" - APP__gears__authenticator__config__redirect_uri: "${AUTHENTICATOR_REDIRECT_URI:-http://localhost:8083/auth/callback}" + # Callback rides the SPA's browser origin (Vite :3000) so the __Host-sid + # cookie lands where the SPA runs; see .env.compose.example. + APP__gears__authenticator__config__redirect_uri: "${AUTHENTICATOR_REDIRECT_URI:-http://localhost:3000/auth/callback}" APP__gears__authenticator__config__service_tokens__audience: "${AUTHENTICATOR_TOKEN_AUDIENCE:-http://authenticator:8093/internal/token}" # Resolves the testclient public_key_paths against the mounted dev keys # (dev-compose.sh generates testclient.pub.pem there; nothing committed). @@ -386,14 +371,28 @@ services: # Compiles the mounted routes.yaml (same file the chart ships) into nginx.conf # at startup; deployment settings come from the environment below. # - # TODO(#1583 step 09 — Destroy): once the legacy api-gateway is deleted this - # takes 8080 unconditionally; drop the `gateway` profile and this note. - # Profile-gated for now to avoid the 8080 clash. `/` -> insight-front must - # resolve at nginx start, so bring up a frontend profile too, e.g.: - # docker compose --profile gateway --profile front-built up gateway insight-front-built + # TLS discovery front for the authenticator (see deploy/compose/authn-tls.conf). + # The analytics oidc-authn-plugin resolves the JWKS via OIDC discovery over + # https ONLY, so this terminates TLS (self-signed CA, trusted by analytics via + # /certs/ca.pem) and proxies the well-known endpoints to the authenticator. + # certs generated by dev-compose.sh into deploy/compose/authn-tls-certs/. + authn-tls: + !!merge <<: *backend-common + image: nginx:1.27-alpine + container_name: insight-authn-tls + depends_on: + authenticator: {condition: service_started} + volumes: + - ./deploy/compose/authn-tls.conf:/etc/nginx/conf.d/default.conf:ro + - ./deploy/compose/authn-tls-certs:/certs:ro + + # Gateway (OpenResty edge) — the DEFAULT :8080 entry (NGINX_BFF): auth_request + # to the authenticator, JWT injection, routing to analytics/identity + the SPA. + # `/` -> insight-front resolves lazily (nginx resolver), so the gateway starts + # even when no frontend profile is up — the /auth + /api surfaces (drivable by + # curl) work regardless. (#1583 step 09 removes the legacy api-gateway.) gateway: !!merge <<: *backend-common - profiles: ["gateway"] image: ${GATEWAY_IMAGE:-insight-gateway:dev} container_name: insight-gateway build: @@ -432,7 +431,7 @@ services: # Vite proxies `/api/*` to this target — used by the FE's # analytics + identity API clients. Must resolve from inside the # frontend container, so we use the compose service name. - VITE_API_PROXY_TARGET: "${VITE_API_PROXY_TARGET:-http://api-gateway:8080}" + VITE_API_PROXY_TARGET: "${VITE_API_PROXY_TARGET:-http://gateway:8080}" VITE_OIDC_ISSUER: "${OIDC_ISSUER:-}" VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}" # Dev impersonation: when set AND a matching row exists in @@ -494,12 +493,12 @@ services: OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} OIDC_SCOPES: ${OIDC_SCOPES:-} volumes: - # Patch script — inserts `location /api/ → api-gateway:8080` into + # Patch script — inserts `location /api/ → gateway:8080` into # the FE image's nginx template at a known marker, then chains to # the original entrypoint. Avoids maintaining a parallel template # (drift surface): we describe just the diff we need. The # published image has no /api proxy because k8s ingress routes - # /api → api-gateway directly; compose has no front-proxy so the + # /api → gateway directly; compose has no front-proxy so the # hop has to land locally. - ./deploy/compose/front-ghcr-patch-template.sh:/compose/front-ghcr-patch-template.sh:ro entrypoint: ["/bin/sh", "/compose/front-ghcr-patch-template.sh"] @@ -536,9 +535,8 @@ services: - | set -eux apt-get update && apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev pkg-config libssl-dev > /dev/null - cargo build --release --bin insight-api-gateway --bin analytics --bin authenticator - mkdir -p /out/api-gateway /out/analytics /out/authenticator - install -m 0755 /target/release/insight-api-gateway /out/api-gateway/insight-api-gateway + cargo build --release --bin analytics --bin authenticator + mkdir -p /out/analytics /out/authenticator install -m 0755 /target/release/analytics /out/analytics/analytics install -m 0755 /target/release/authenticator /out/authenticator/authenticator diff --git a/docs/components/backend/authenticator/DESIGN.md b/docs/components/backend/authenticator/DESIGN.md index 5b7295cfd..66820c320 100644 --- a/docs/components/backend/authenticator/DESIGN.md +++ b/docs/components/backend/authenticator/DESIGN.md @@ -46,7 +46,7 @@ date: 2026-07-06 - [DD-AUTH-06: Two Listeners for Two Internal Surfaces](#dd-auth-06-two-listeners-for-two-internal-surfaces) - [DD-AUTH-07: Access-Control Claims Fetched Once, at Login](#dd-auth-07-access-control-claims-fetched-once-at-login) - [DD-AUTH-08: Empty-Table First-Admin Bootstrap plus INSTALLER](#dd-auth-08-empty-table-first-admin-bootstrap-plus-installer) - - [RESOLVED (step 04): ES256 for the Gateway JWT](#resolved-step-04-es256-for-the-gateway-jwt) + - [RESOLVED (step 07): ES256 for the Gateway JWT](#resolved-step-07-es256-for-the-gateway-jwt) - [6. Traceability](#6-traceability) @@ -96,7 +96,7 @@ The authenticator is a plain HTTP service: no proxying, no K8s API access, no st | `cpt-insightspec-nfr-auth-rate-limit` | Layer-2 precise limits | Auth Controller | Redis token bucket by session/user + login-state cap | Flood test: 429 at cap, bounded Redis entries | | `cpt-insightspec-nfr-auth-fail-closed` | No auth without Redis | Session Manager | No local cache; readiness = Redis + keys loaded | Kill Redis; verify 401/503 + not-ready | -**ADRs**: to be authored alongside implementation; decisions captured inline in [section 5](#5-design-decisions) until then, including the carried-over and superseded decisions from the deleted API Gateway spec tree. +**ADRs**: [`cpt-insightspec-adr-auth-0001-per-environment-idp-selection`](specs/ADR/0001-per-environment-idp-selection.md) -- which IdP backs the authenticator's OIDC client per environment (fakeidp for all dev environments -- CI, compose, and local k8s -- because it injects the `tenant_id` claim with zero extra infrastructure; Dex, heavy brokers, and a dev-login endpoint rejected; the production IdP/broker deferred), realising `cpt-insightspec-fr-auth-oidc-login` wiring without a code change. Remaining decisions are captured inline in [section 5](#5-design-decisions) until extracted alongside implementation, including the carried-over and superseded decisions from the deleted API Gateway spec tree. ### 1.3 Architecture Layers @@ -375,7 +375,7 @@ Does not verify inbound JWTs (the host's auth pipeline does, for the admin surfa No-user workloads need signed identity without a secret in transit. ##### Responsibility scope -`POST /internal/token` on the second listener: validate the RFC 7523 assertion (signature against the registry's public keys, `aud`, `exp` at most 60 s), require a tenant scope (reject `400` if none), replay-guard `jti` (`SET NX`, same pattern as `asm:logout_jti`), audit, and mint `sub = service:` with registry roles and the requested `tenants`. +`POST /internal/token` on the second listener: validate the RFC 7523 assertion (signature against the registry's public keys, `aud`, `exp` at most 60 s), require a tenant scope (reject `400` if none), replay-guard `jti` (`SET NX`, same pattern as `asm:logout_jti`), audit, and mint a token with a stable per-service UUID `sub` (deterministic v5), `sub_type = service`, registry roles (including `service`), and the requested single `tenant_id`. ##### Responsibility boundaries Does not manage the registry (gitops does). Does not issue user tokens. @@ -543,7 +543,7 @@ sequenceDiagram Note over B: IdP tokens stay HERE, browser never sees them.
Refreshed in background; a definitive refusal later
kills all linked session tokens. B->>ID: resolve author: person_id, tenant(s) Note over B: access-control claims: ONE call at login to the
permissions service (built later) -- until then
default roles from config - B->>B: create session: stable session_id (UUIDv7)
+ session token (opaque credential, CSPRNG)
mint linked JWT: sub=person_id, tenants, roles,
sid=session_id, exp=iat+300s + B->>B: create session: stable session_id (UUIDv7)
+ session token (opaque credential, CSPRNG)
mint linked JWT: sub=person_id, tenant_id, roles,
sub_type=user, sid=session_id, exp=iat+300s B->>R: one pipeline: session record + token mapping
+ linked JWT + indexes + refresh schedule B-->>U: Set-Cookie __Host-sid=(session token) + 302 to SPA ``` @@ -687,11 +687,11 @@ sequenceDiagram participant R as Redis S->>S: sign assertion: iss=sub=service, aud=authenticator,
jti, exp <= 60s (private key) - S->>B: POST /internal/token (assertion + tenants=[t]) + S->>B: POST /internal/token (assertion + tenant=t) B->>B: verify signature against registry public keys Note over B: reject 400 if no tenant named (tenant isolation) B->>R: SET jti NX (replay guard) - B-->>S: gateway JWT: sub=sid=service:name, roles per registry,
tenants:[t] (required), TTL 300s, same key + JWKS + B-->>S: gateway JWT: sub=, sub_type=service,
sid=service:name, roles per registry,
tenant_id=t (required), TTL 300s, same key + JWKS S->>S: cache per tenant, re-request before expiry ``` @@ -740,7 +740,7 @@ graph LR | Field | Type | Description | |---|---|---| | `person_id` | String | Internal person identifier | -| `tenants` | String (JSON) | Tenant memberships resolved at login -- the JWT's `tenants` source | +| `tenants` | String (JSON) | Tenant memberships resolved at login -- the JWT's single `tenant_id` is minted from the first entry | | `roles` | String (JSON) | Access-control snapshot fetched at login (default roles until the permissions service exists) | | `idp_iss` | String | OIDC issuer URL | | `idp_sub` | String | OIDC subject | @@ -801,22 +801,23 @@ graph LR Technical specification of `cpt-insightspec-contract-auth-gateway-jwt` ([PRD section 7.2](./PRD.md#72-external-integration-contracts)). This schema **supersedes the deleted spec's DD-ROUTER-05** (identity-only JWT, all authorization downstream): the JWT is the signed, complete description of the request author. -**Header**: `alg` (see the open EdDSA vs ES256 decision in [section 5](#open-eddsa-vs-es256-for-the-gateway-jwt)), `typ: JWT`, `kid` from JWKS. +**Header**: `alg: ES256` (see the algorithm decision in [section 5](#resolved-step-07-es256-for-the-gateway-jwt)), `typ: JWT`, `kid` from JWKS. | Claim | Type | Value | |---|---|---| -| `sub` | String | Internal **person_id**; `service:` for service tokens | -| `tenants` | Array of String | For a user token: all tenant memberships (1..N), resolved at login. For a **service token**: the tenant(s) the caller requested -- always present, since service tokens are mandatorily tenant-scoped (DD-AUTH-05). The JWT is the only tenant **authority**; per-request **selection** is an unsigned attribute (`X-Tenant-ID` or path segment) that downstream validates against this signed set: selector missing when needed = 400; selector not in the set = 403. An unsigned header can no longer grant anything -- the worst it can do is pick among tenants the JWT already granted | -| `roles` | Array of String | Default from config (`["user"]`); the permissions service's login-time answer later replaces the values, never the shape. Service tokens carry `["service", ...]` per the registry | -| `sid` | String | **Stable** session id (UUIDv7) -- survives cookie rotations; one id from login to logout for tracing, audit, and the JWT/session linkage. **Service tokens** have no session, so `sid = service:` (equal to `sub`): a non-empty, stable value that keeps the claim shape fixed (never optional) and correlates a service's issuance in audit/trace | +| `sub` | String (UUID) | Internal **person_id**; for a **service token**, a stable per-service UUID (deterministic v5, not a `service:` string) | +| `tenant_id` | String (UUID) | The **single** tenant this token is scoped to -- the sole tenant authority. A user token carries the session's tenant; a service token carries the caller's requested tenant (mandatorily tenant-scoped, DD-AUTH-05). **Required**: a token without a valid `tenant_id` is rejected downstream (no Nil fallback). Multi-tenant-in-token and the `X-Tenant-ID` selector are dropped -- a tenant from the outside world never passes | +| `roles` | String | Space-delimited access-control scopes (OAuth `scope` shape), default `user`; the permissions service's login-time answer later replaces the values, never the shape. Service tokens include `service`. (Serialized as a space-delimited string, not a JSON array, so the plugin's `token_scopes` mapping — `as_str().split_whitespace()` — reads it.) | +| `sub_type` | String | `user` for people, `service` for service tokens (maps to `SecurityContext.subject_type`) | +| `sid` | String | **Stable** session id (UUIDv7) -- survives cookie rotations; one id from login to logout for tracing, audit, and the JWT/session linkage. **Service tokens** have no session, so `sid = service:`: a non-empty, stable value that keeps the claim shape fixed (never optional) and correlates a service's issuance in audit/trace | | `iss` | String | Gateway host issuer URL | | `aud` | String | `internal-services` | | `iat` / `exp` | Int | `exp = iat + 60..300 s` (default TTL 300 s) | | `jti` | String | UUIDv7 | -**SecurityContext alignment (load-bearing).** Downstream gears construct caller identity from claims via the authn-resolver claim mapper into `toolkit_security::SecurityContext`. The claims map 1:1: `sub` maps to `subject_id` (`service:` yields `subject_type = "service"`), `roles` maps to `token_scopes`, and the validated tenant selection maps to `subject_tenant_id`. `SecurityContext` is single-tenant by design -- which is exactly the authority/selection split: `tenants[]` in the JWT is the authority; the shared verification middleware resolves selector membership and constructs the context with the *selected* tenant. +**SecurityContext alignment (load-bearing).** Downstream gears construct caller identity from claims via the `cf-gears-oidc-authn-plugin` claim mapper into `toolkit_security::SecurityContext`, configured by `claim_mapping`. The claims map 1:1: `sub` → `subject_id` (parsed as a UUID), `tenant_id` → `subject_tenant_id` (**required**), `sub_type` → `subject_type`, `roles` → `token_scopes`. `SecurityContext` is single-tenant by design, which now matches the token exactly: one signed `tenant_id`, no authority/selection split, no bespoke mapping layer. -**Verification at downstream**: signature via JWKS (`GATEWAY_JWKS_URL`), `iss`, `aud`, `exp` -- no shared secrets. Mandatory for every service, fail closed, no production disable knob: a gateway misconfiguration that skips auth yields a JWT-less request downstream and a 401 -- an availability bug, never a breach. The gateway's own check is UX and hot-path efficiency, not the security boundary. +**Verification at downstream**: signature via JWKS (resolved through OIDC discovery on `iss`), `iss`, `aud`, `exp`, and the required `tenant_id` -- no shared secrets. Mandatory for every service, fail closed, no production disable knob: a gateway misconfiguration that skips auth yields a JWT-less request downstream and a 401 -- an availability bug, never a breach. The gateway's own check is UX and hot-path efficiency, not the security boundary. ### 3.9 Configuration Surface @@ -958,7 +959,7 @@ Recorded here so the decisions survive the deleted tree; rationale as originally ### Superseded decisions -- **DD-ROUTER-05 (identity-only JWT, all authorization downstream) -- SUPERSEDED** by DD-AUTH-04: the JWT now carries `tenants` and `roles`; downstream services still make the final authorization decision, but from signed claims instead of per-request identity lookups and unsigned tenant headers. +- **DD-ROUTER-05 (identity-only JWT, all authorization downstream) -- SUPERSEDED** by DD-AUTH-04: the JWT now carries a single `tenant_id` and `roles`; downstream services still make the final authorization decision, but from signed claims instead of per-request identity lookups and unsigned tenant headers. - **The BFF spec's "no IdP token refresh in v1" carve-out -- SUPERSEDED** by DD-AUTH-03: the authenticator stores and background-refreshes IdP tokens; sessions die on definitive IdP refusal. - **DD-BFF-10's swap-key rotation pipeline -- SUPERSEDED** in mechanism by DD-AUTH-02 (the grace *semantics* are preserved by the old mapping's TTL; the rotation, grace, and jitter *intent* of DD-BFF-10 carries over with the bigger jitter window). - **The single-binary gateway NFR (`nfr-gw-single-binary` in the deleted tree) -- deliberately violated and retired**: that NFR existed to avoid a hop between auth and routing; this architecture reintroduces the hop deliberately and absorbs it with the gateway-side exchange cache. @@ -998,25 +999,25 @@ Recorded here so the decisions survive the deleted tree; rationale as originally ### DD-AUTH-04: Tenants and Roles in the JWT -**Decision**: The JWT carries `tenants[]` (the only tenant authority) and `roles` from day one. Per-request tenant selection stays an unsigned attribute validated against the signed set inside the shared verification middleware. +**Decision**: The JWT carries a **single** signed `tenant_id` (the only tenant authority) and `roles` from day one. There is **no** per-request tenant selection: the token names exactly one tenant, minted from the session. (An earlier cut carried `tenants[]` plus an `X-Tenant-ID` selector validated downstream; that was dropped in step 07 — multi-tenant-in-token "leads to errors mostly and may not be used at all", and the single-tenant shape matches `SecurityContext` and the upstream plugin's `subject_tenant_id` 1:1, with no bespoke selection middleware.) **Why**: -- Headers are not signed; an unsigned `X-Insight-Tenant-Id` as a trust source is exactly the anti-pattern this design deletes. Demoting it to a validated selector means the worst a forged header can do is pick among tenants the JWT already granted. -- The SPA keeps sending `X-Tenant-ID` exactly as today; switching tenant in the UI is just a different selector -- no session mutation, no JWT reissue. +- Headers are not signed; an unsigned `X-Insight-Tenant-Id`/`X-Tenant-ID` as a trust source is exactly the anti-pattern this design deletes. With a single signed `tenant_id` there is nothing for an outside header to select — a tenant from the outside world never passes, full stop. +- One signed `tenant_id` maps directly to the plugin's `subject_tenant_id` (required), so the whole authority/selection split — and the 400/403 selector logic it needed downstream — disappears. - Fixing the claim shape now means the permissions service later changes claim values, never the contract; downstream services code against the final shape from day one. -**Consequences**: Membership and role changes propagate on re-login; the instant lever is session revocation (the permissions service calls the admin revoke on grant change). Claim size stays bounded: memberships are 1..few in practice; if a deployment ever has persons in hundreds of tenants, cap the claim and fall back to selector + membership lookup -- a downstream-only change. +**Consequences**: Membership and role changes propagate on re-login; the instant lever is session revocation (the permissions service calls the admin revoke on grant change). Switching the active tenant in the UI is a re-login / new token rather than a per-request selector. If a future need for multi-tenant fan-out appears, it is a downstream-only change (per-tenant tokens), not a claim-contract change. ### DD-AUTH-05: Service Tokens via RFC 7523 Assertions and a Public-Key Registry -**Decision**: `POST /internal/token` accepts `private_key_jwt` assertions verified against a gitops-reviewable registry (service name, public keys, roles); output is a normal gateway JWT with `sub = service:`. **Service tokens are always tenant-scoped**: the request must name a tenant (`tenants=[...]`); one that names none is rejected `400 tenant_required`. There is no cross-tenant service token and no per-service tenant flag. +**Decision**: `POST /internal/token` accepts `private_key_jwt` assertions verified against a gitops-reviewable registry (service name, public keys, roles); output is a normal gateway JWT with a stable per-service UUID `sub` (deterministic v5) and `sub_type = service` (the `service:` string survives only as the human-readable `sid`). **Service tokens are always tenant-scoped**: the request must name a tenant; one that names none is rejected `400 tenant_required`. There is no cross-tenant service token and no per-service tenant flag. **Why**: - Against static client secrets: no secret in transit, rotation is a reviewable PR shipping key n+1 alongside n. - Against K8s ServiceAccount projected tokens: those bind auth to k8s (compose/e2e need a parallel mechanism) and couple the authenticator to the apiserver -- a good v2 convenience layer, wrong base. - Against mTLS/SPIFFE: strongest story, but drags in cert infra or a mesh for a handful of services. - One verification path downstream; the permissions service's authenticated path to session-revoke falls out for free (its registry entry grants the revoke role). -- **Mandatory tenant** enforces tenant isolation for machine work: every service token is scoped to the tenant it acts on, so a downstream service sees the same `tenants[]` authority contract for user and service traffic. This **supersedes NGINX_BFF.md §10 G1's "no tenants claim = cross-tenant service work"** -- where a service legitimately needs to fan out across tenants, it requests one token per tenant. Where the service gets its tenant is the service's concern. +- **Mandatory tenant** enforces tenant isolation for machine work: every service token is scoped to the tenant it acts on, so a downstream service sees the same single-`tenant_id` authority contract for user and service traffic. This **supersedes NGINX_BFF.md §10 G1's "no tenants claim = cross-tenant service work"** -- where a service legitimately needs to fan out across tenants, it requests one token per tenant. Where the service gets its tenant is the service's concern. **Consequences**: No key material is committed -- dev/e2e generate a throwaway keypair at bring-up (like the gateway signing key) and reference its public half via `public_key_paths`; the flow is identical outside k8s. Assertion `jti` replay uses the existing `SET NX` pattern. A future genuinely cross-tenant service (should one arise) would need an explicit new decision, not a silent unscoped token. @@ -1046,9 +1047,15 @@ Recorded here so the decisions survive the deleted tree; rationale as originally **Consequences**: The documented race ("first colleague to log in wins the universe") is bounded to IdP-authenticated principals on an empty install and is loudly audited; security-sensitive installs disable it. -### RESOLVED (step 04): ES256 for the Gateway JWT +### RESOLVED (step 07): ES256 for the Gateway JWT -**Decision: mint ES256 (ECDSA P-256 / SHA-256).** The deleted spec mandated EdDSA (DD-BFF-05: small signatures, fast verify), but the downstream verifier (oidc-authn-plugin) validates RS256/ES256 today; EdDSA would need a non-trivial plugin extension, and downstream verification is a later phase (the R1 rule). ES256 satisfies DD-BFF-05's rationale -- 64-byte signatures, fast verify -- with **zero downstream friction**. The authenticator's Key Store loads a mounted PKCS#8 EC P-256 private key (`current.pem` + optional `previous.pem`), the `kid` is the RFC 7638 JWK thumbprint (stable, no manifest), and `/.well-known/jwks.json` publishes the EC public keys. If a future need for EdDSA arises, the plugin extension remains the documented upgrade path (it changes only the signing alg, not the claim contract). Implemented in step 04 before any signing code shipped. +**Decision: mint ES256 (ECDSA P-256 / SHA-256).** Downstream verification (step 07) is done by the **upstream `cf-gears-oidc-authn-plugin`**, which resolves the JWKS through `jsonwebtoken`'s EC-capable `jwk::JwkSet` — so it validates EC keys natively. ES256 is the right default: 64-byte signatures and fast verification, and the gateway JWT rides in an HTTP header on **every** downstream request, so those bytes and cycles are paid per call. `none` is always rejected; the plugin's `supported_algorithms` is pinned to `["ES256"]`. + +(An earlier step-07 cut briefly fell back to RS256 because the insight-local *fork* of the plugin used `cf-gears-toolkit-auth`'s RSA-only JWKS parser. That fork has been dropped in favour of the published plugin; the toolkit-auth RSA-only limitation is now tracked purely as an upstream enhancement in constructorfabric/gears-rust#4215 and no longer constrains this design.) + +The authenticator's Key Store loads a mounted PKCS#8 **EC P-256** private key (`current.pem` + optional `previous.pem`); the `kid` is the RFC 7638 EC JWK thumbprint (stable, no manifest); `/.well-known/jwks.json` publishes the EC public keys (`kty=EC`, `crv=P-256`, `x`/`y`). + +**Discovery front (deployment note).** The plugin resolves the JWKS via OIDC **discovery** (`{issuer}/.well-known/openid-configuration` → `jwks_uri`), not from a directly-configured JWKS URL, and its runtime URL policy is **https-only**. So the authenticator also serves `GET /.well-known/openid-configuration` (a minimal doc: `issuer` + `jwks_uri`, both derived from `gateway_issuer`), and in production the issuer is a real https origin. In local dev / the step-07 e2e — which run over http — a tiny TLS front (self-signed cert, trusted by downstream via `http_client.custom_ca_certificate_paths`) terminates https for the well-known endpoints, so the real (https-only) code path is exercised rather than a dev-only insecure toggle. (The RFC 7523 service-token *client assertions* stay ES256 too — verified against the services' own EC keys in the registry, not via the plugin's JWKS.) ## 6. Traceability 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 new file mode 100644 index 000000000..e6131d2a8 --- /dev/null +++ b/docs/components/backend/authenticator/specs/ADR/0001-per-environment-idp-selection.md @@ -0,0 +1,187 @@ +--- +status: accepted +date: 2026-07-16 +--- + +# ADR-0001: Per-Environment IdP Selection (fakeidp for all dev environments; production broker deferred) + +**ID**: `cpt-insightspec-adr-auth-0001-per-environment-idp-selection` + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Option A -- fakeidp for all dev environments, chosen](#option-a----fakeidp-for-all-dev-environments-chosen) + - [Option B -- Dex for local k8s](#option-b----dex-for-local-k8s) + - [Option C -- heavy broker (Keycloak / Authentik / Zitadel) for local k8s](#option-c----heavy-broker-keycloak--authentik--zitadel-for-local-k8s) + - [Option D -- authenticator dev-login endpoint](#option-d----authenticator-dev-login-endpoint) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +## Context and Problem Statement + +The authenticator is a confidential OIDC client (`cpt-insightspec-fr-auth-oidc-login`): it logs +in against an upstream IdP, then mints the session-linked ES256 gateway JWT. Since the NGINX_BFF +rollout (EPIC #1583) auth is enforced **everywhere** (R1 -- every downstream verifies the gateway +JWT; there is no `auth_disabled` path). That change retired the old developer shortcut, where the +frontend stamped a dev email into a runtime `oidc-config.js` and the browser forged an *unsigned* +bearer -- it only ever worked because the gateway trusted unauthenticated requests. With real +downstream verification that forged bearer is now a 401, so a non-production login path must still +produce a **genuinely signed** session. + +Every non-production environment therefore needs *some* IdP for the authenticator to log in +against, spanning CI / e2e (headless, deterministic, instant), compose (the fast disposable inner +loop), and local k8s (the heavyweight, prod-shaped environment used for FE / browser work). + +Two properties make the choice non-trivial: + +- The full stack is **tenant-scoped**: analytics rejects a session that carries no Insight tenant. + The authenticator resolves the Insight `tenant_id` at the **authentication** boundary -- it maps + an external/IdP tenant identifier to the Insight tenant -- so whatever IdP is used in dev must let + that claim reach the session, or the app 401s past the login page. +- On local k8s the IdP's browser-facing authorization endpoint must be reachable from the browser, + while the token/JWKS back-channel must be reachable from the authenticator pod -- from one + consistent issuer URL. The session cookie (`__Host-sid`) is `Secure`, so the browser only accepts + it over a **secure context** (HTTPS or `http://localhost`), which constrains the callback origin. + +## Decision Drivers + +- Auth is always on (R1): any dev login must mint a real signed session, not a mock bearer. +- Use the **published ghcr images unchanged** -- a per-environment login path must not require + rebuilding a service image. +- The whole stack (analytics tenant-scoping included) must work end-to-end in dev, which means the + Insight `tenant_id` claim must be present in the dev session. +- CI must stay headless and deterministic; compose must stay fast; local k8s should be as close + to production as is reasonable while adding no new infrastructure. +- The IdP's issuer URL in k8s must be reachable from both the browser and the authenticator pod, + and the browser callback must land on a secure context so `__Host-sid` is accepted. +- Prefer lightweight infrastructure -- avoid dragging a database / Redis / JVM into a dev IdP. +- Keep the authenticator IdP-agnostic so a production provider slots in later without code changes. + +## Considered Options + +- **Option A (chosen)** -- fakeidp for **all** dev/test environments (CI, compose, and local k8s). +- **Option B** -- Dex (`dexidp/dex`) for local k8s. +- **Option C** -- a heavy broker (Keycloak / Authentik / Zitadel) for local k8s. +- **Option D** -- an authenticator dev-login endpoint (`/auth/dev-login?email=...`). + +## Decision Outcome + +Chosen: **Option A**. **fakeidp is the IdP for every dev/test environment -- CI, compose, and +local k8s.** fakeidp already auto-approves a configured dev user and, crucially, injects the +non-standard `tenants` claim, so the full stack (analytics tenant-scoping included) works +end-to-end with **zero extra infrastructure** -- no new database, Redis, or JVM. + +On local k8s, fakeidp is exposed through the ingress at nginx path `/idp` with a rewrite (fakeidp +serves its OIDC routes at root), so the browser login flow reaches it and the authenticator pod +shares the same issuer URL. The browser-facing callback uses `http://localhost/auth/callback` +because the `__Host-sid` session cookie is `Secure` and browsers only accept it over a secure +context (HTTPS or `http://localhost`). + +**A production-grade IdP / broker is explicitly deferred to a production decision.** The +authenticator is IdP-agnostic -- `authenticator.oidc.issuerUrl` is a plain config value -- so any +OIDC provider slots in without a code change, and it does not need to run locally to develop +against. See More Information for the leading candidates when that decision is made. + +### Consequences + +- **CI / compose / local k8s** now all run the same dev IdP (fakeidp), so there is one mechanism + to reason about and one way the `tenant_id` claim reaches the session. +- **No new infrastructure**: fakeidp is a single small service with no backing store; dev does not + pay for a database / Redis / JVM to have a working login. +- **Tenant scoping works in dev**: because `tenant_id` is an Insight-domain claim resolved at the + authentication boundary (a mapping from an external/IdP tenant identifier to the Insight tenant, + **not** something computed in the identity-resolution/persons service), fakeidp's injected + `tenants` claim is exactly what lets analytics return data instead of 401. +- **Local k8s wiring**: fakeidp is reached through the ingress (`/idp` + rewrite) so browser and + pod share one issuer; the browser callback is pinned to `http://localhost/auth/callback` to + satisfy the `__Host-sid` secure-context requirement. This is a routing/config concern, not a + code path in the authenticator. +- **Production is unblocked but undecided**: because the authenticator is IdP-agnostic, deferring + the production IdP costs nothing today -- the eventual provider is a config change plus its own + provisioning, with no authenticator rebuild. +- **Trade-off accepted**: fakeidp does not exercise a real login page, refresh, logout, or + consent, so those integration behaviours are validated later against the real production IdP + rather than locally. This is deliberate: it keeps dev free of heavy IdP infrastructure. + +### Confirmation + +- `cfs validate --local-only` passes for this ADR and the authenticator DESIGN. +- On local k8s, a browser login through fakeidp (via `/idp`) lands on + `http://localhost/auth/callback`, the `__Host-sid` cookie is set, and an authenticated request + returns 200 while an unauthenticated one returns 401. +- Analytics returns tenant-scoped data (not `AUTHN_FAILED`) because the session carries the + fakeidp-injected `tenants` claim -- the exact check that fails under a claim-less IdP. + +## Pros and Cons of the Options + +### Option A -- fakeidp for all dev environments, chosen + +- Good, because it is one mechanism across CI, compose, and local k8s -- one thing to reason about. +- Good, because it injects the non-standard `tenants` claim, so analytics tenant-scoping and the + full stack work end-to-end with zero extra infrastructure (no database, Redis, or JVM). +- Good, because auto-approve is ideal for CI and keeps compose fast. +- Good, because it needs no authenticator code change and preserves the published-image contract. +- Bad, because it never exercises a real login page, refresh, logout, or consent -- those surface + against the production IdP later, not locally. Accepted, given the zero-infrastructure win. +- Neutral: local k8s needs ingress wiring (`/idp` + rewrite) and a `http://localhost` callback for + the `__Host-sid` secure-context rule -- routing/config, not code. + +### Option B -- Dex for local k8s + +- Good, because Dex is a tiny single Go binary, database-free in dev, config-only, and can act + both as a broker and as a standalone provider with a real login page. +- Bad (fatal), because Dex **cannot emit custom claims** -- there is no way to put the Insight + `tenant_id` into the token. Verified end-to-end: after a real Dex browser login, identity returns + 200 but analytics returns `AUTHN_FAILED` (401) because the session has no tenant. +- Bad, because relying on the IdP to assert *your* Insight tenant is the wrong layer anyway: a real + IdP (e.g. Entra) emits its own tenant id (`tid`), never your Insight tenant UUID, so the + external-to-Insight mapping must live at the authentication boundary regardless. + +### Option C -- heavy broker (Keycloak / Authentik / Zitadel) for local k8s + +- Good, because these **do** satisfy the full feature set: federation broker *and* standalone + provider, a custom `tenant_id` claim, and a UUID `sub` -- the most production-like option. +- Bad, because each drags in a database / Redis / JVM and realm/tenant administration -- + disproportionate operational footprint for a dev IdP ("don't bring more databases to a small + install"). Rejected on footprint, not capability. + +### Option D -- authenticator dev-login endpoint + +- Good, in principle, because it mints a real signed session and removes the IdP hop entirely, so + there is no browser-reachability or claim-injection problem at all. +- Bad (fatal), because the published ghcr images are built without such a flag; using it locally + would force a custom image rebuild -- the exact friction we are trying to avoid. +- Bad, because it is a login-bypass backdoor in a security-critical service; even compile-time + gating adds a permanently sensitive surface. Rejected. + +## More Information + +- The Insight `tenant_id` is an **Insight-domain claim resolved at the authentication boundary** -- + a mapping from an external/IdP tenant identifier to the Insight tenant. It is **not** produced by + the identity-resolution/persons service. This is why the IdP choice hinges on getting that claim + into the session, and why a claim-less IdP (Dex) fails analytics. +- `__Host-sid` requires a **secure context**, so the local-k8s browser callback is + `http://localhost/auth/callback` (HTTPS or `http://localhost` only); this constraint, not + preference, pins the callback origin. +- When a real IdP **is** needed in production, the leading lightweight candidate is **Casdoor** + (Go, can reuse the existing MariaDB -- no new database engine -- with custom claims, standalone + and broker modes, and a UUID subject). **Keycloak** is the battle-tested heavyweight reference. + Both keep the authenticator unchanged, since the IdP is only `authenticator.oidc.issuerUrl`. +- EPIC #1583 (NGINX_BFF) establishes R1 (auth everywhere) -- see `NGINX_BFF.md` and the gateway + ADR `cpt-insightspec-adr-gw-0001-access-by-lua-over-auth-request`. +- Identity brokering / production IdP investigation: #1782. + +## Traceability + +- Realises the environment wiring behind `cpt-insightspec-fr-auth-oidc-login` (the authenticator's + OIDC client) without changing its contract. +- Constrains the deployment story for the authenticator's IdP dependency across CI, compose, and + local k8s, and defers the production IdP to a later decision. diff --git a/docs/components/backend/gateway/DESIGN.md b/docs/components/backend/gateway/DESIGN.md index f1594d941..bb4ac306a 100644 --- a/docs/components/backend/gateway/DESIGN.md +++ b/docs/components/backend/gateway/DESIGN.md @@ -331,12 +331,12 @@ defaults: - X-Real-IP - Forwarded routes: - - prefix: /api/v1/analytics + - prefix: /api/analytics upstream: http://analytics.insight.svc.cluster.local:8081 timeout_ms: 60000 strip_prefix: false - - prefix: /api/v1/identity + - prefix: /api/identity upstream: http://identity.insight.svc.cluster.local:8082 - prefix: /api/v1/stream @@ -352,9 +352,9 @@ Validation rules (enforced by the configurator in CI, before nginx ever sees the - `prefix` must start with `/api/`. - `upstream` must be a valid URL with hostname and port. - `timeout_ms >= 0`; `0` only allowed when `websocket: true`. -- `strip_request_headers` entries must be valid HTTP header names; reserved gateway headers (`Authorization`, `X-Correlation-Id`, `X-Forwarded-*`, gateway cookies) **MUST NOT** appear in this list -- they are stripped unconditionally. `X-Tenant-ID` is **not** reserved and must pass through (it is the tenant selector the downstream middleware validates against the signed `tenants[]`). +- `strip_request_headers` entries must be valid HTTP header names; reserved gateway headers (`Authorization`, `X-Correlation-Id`, `X-Forwarded-*`, gateway cookies) **MUST NOT** appear in this list -- they are stripped unconditionally. There is no tenant selector anymore: the JWT carries a single signed `tenant_id`, so an inbound `X-Tenant-ID`/`X-Insight-Tenant-Id` is not authority and downstream ignores it (deployments may strip it for hygiene). -**Why the defaults strip `X-Real-IP` and `Forwarded` -- and how backends still get the client IP.** Those two are *inbound, client-writable* identity headers: the gateway never sets them, so any value arriving upstream could only have come from the browser -- an attacker sending `Forwarded: for=1.2.3.4` would spoof IP-based audit trails, rate-limit keys, or geo logic in any backend that reads them. Stripping them leaves exactly **one source of client-IP truth**: the `X-Forwarded-For` chain, which the gateway strips from the client unconditionally (reserved set) and re-writes itself (hygiene block item 5), resolving the true peer address via `set_real_ip_from` trust of the ingress hops. Backends read client IP from that header and nothing else; the authenticator's session records (`ip` captured at login) rely on the same chain. Same trust model as the tenant selector: an unsigned inbound header is never authority. If an upstream ever genuinely needs `X-Real-IP`, the configurator emits it gateway-written (`$remote_addr` after real-ip resolution) as a hygiene-block addition -- do not remove it from the strip list, which would reintroduce the client-writable variant. +**Why the defaults strip `X-Real-IP` and `Forwarded` -- and how backends still get the client IP.** Those two are *inbound, client-writable* identity headers: the gateway never sets them, so any value arriving upstream could only have come from the browser -- an attacker sending `Forwarded: for=1.2.3.4` would spoof IP-based audit trails, rate-limit keys, or geo logic in any backend that reads them. Stripping them leaves exactly **one source of client-IP truth**: the `X-Forwarded-For` chain, which the gateway strips from the client unconditionally (reserved set) and re-writes itself (hygiene block item 5), resolving the true peer address via `set_real_ip_from` trust of the ingress hops. Backends read client IP from that header and nothing else; the authenticator's session records (`ip` captured at login) rely on the same chain. Same trust model as the tenant claim: an unsigned inbound header is never authority — only the signed gateway JWT is. If an upstream ever genuinely needs `X-Real-IP`, the configurator emits it gateway-written (`$remote_addr` after real-ip resolution) as a hygiene-block addition -- do not remove it from the strip list, which would reintroduce the client-writable variant. ### 3.9 Generated Location Hygiene Block @@ -514,7 +514,9 @@ One OpenResty Deployment behind the single ingress backend, per the edge chain f **Why**: In the deleted Rust Router, auth was structural (every request passed the middleware chain by construction); in nginx it is per-location config. The containment is this rule: a route missing auth means a JWT-less request downstream and a 401 -- an availability bug caught by the first smoke test, never a breach. This is what zero trust means here: no service trusts network position, headers, or another service's word; only the signature. -**Consequences**: CI asserts every `/api/` route returns 401 without a cookie; downstream verification ships as one shared middleware so a new service gets the boundary by adding a dependency (implementation phase 3 of the plan). +**Consequences**: CI asserts every `/api/` route returns 401 without a cookie; downstream verification ships as one shared middleware so a new service gets the boundary by adding a dependency. + +**Realized (step 07)**: The algorithm is **ES256** (§9.6, ECDSA P-256) — smaller signatures (~64 B vs RSA's ~256 B) and faster verify, both paid on every downstream request. Downstream verification is entirely **plugin-native**: Rust services enable host auth via the **upstream `cf-gears-oidc-authn-plugin`** (the insight-local fork and the bespoke `authverify` crate are both **deleted**), which verifies signature / `iss` / `aud` / `exp` / the required `tenant_id` and maps the signed claims straight to a `SecurityContext` via configured `claim_mapping` (`sub`→`subject_id`, `tenant_id`→`subject_tenant_id`, `sub_type`→`subject_type`, `roles`→`token_scopes`). There is **no tenant selector**: the JWT carries a single signed `tenant_id`, so the `X-Tenant-ID`/`X-Insight-Tenant-Id` header trust paths are gone (a tenant from the outside world never passes). analytics adopts the plugin (its `auth_disabled` trust path deleted). identity (.NET) turns on full `JwtBearer` validation (pinned to `EcdsaSha256`) against the authenticator's JWKS, reads the caller from `sub` and the tenant from the single `tenant_id` claim, and fails closed via a `RequireAuthenticatedUser` fallback policy. The plugin resolves the JWKS via OIDC **discovery** (`{issuer}/.well-known/openid-configuration` → `jwks_uri`) over **https only**; in production the issuer is a real https origin, and in dev/e2e a self-signed TLS front serves the authenticator's well-known endpoints (trusted via `http_client.custom_ca_certificate_paths`). Proven end-to-end by `services/gateway/tests/step07/` (the §D scenarios). ### Carried over and known issues diff --git a/scripts/ci/changed.py b/scripts/ci/changed.py old mode 100644 new mode 100755 index ed9d8eaf3..bae4cee58 --- a/scripts/ci/changed.py +++ b/scripts/ci/changed.py @@ -10,6 +10,7 @@ (a crate may be lint-only); dotnet carries solution + cover (a component may be test-only, e.g. identity); python carries cov_package. """ + from __future__ import annotations import argparse @@ -17,7 +18,7 @@ import subprocess import sys -from components import ROOT, COMPARE_BRANCH, COMPONENTS, component_for +from components import COMPARE_BRANCH, COMPONENTS, ROOT, component_for # The src/backend Cargo workspace. fmt+clippy run per crate in the rust matrix, # but linting has cross-crate reach: a change to a workspace-level Rust file @@ -29,8 +30,7 @@ _SHARED_BACKEND_DIRS = ("src/backend/libs/", "src/backend/plugins/") -def _matrix_entry(comp: dict, *, lint: bool = False, cover: bool = True, - test: bool = False) -> dict: +def _matrix_entry(comp: dict, *, lint: bool = False, cover: bool = True, test: bool = False) -> dict: """The fields a producer CI job needs for one component. Rust entries also carry lint/cover/test flags (a rust crate may appear lint-only; a changed crate with cover=false still runs plain `cargo test`).""" @@ -67,8 +67,7 @@ def changed_components(compare_branch: str, components: list[dict]) -> dict[str, coverage is strictly per-changed-crate, while a shared backend Rust change fans lint (only) out to every backend crate.""" out = subprocess.run( - ["git", "diff", "--name-only", f"{compare_branch}...HEAD"], - cwd=ROOT, capture_output=True, text=True, check=True, + ["git", "diff", "--name-only", f"{compare_branch}...HEAD"], cwd=ROOT, capture_output=True, text=True, check=True ).stdout by_name = {c["name"]: c for c in components} changed: set[str] = set() @@ -81,8 +80,11 @@ def changed_components(compare_branch: str, components: list[dict]) -> dict[str, if name: changed.add(name) comp = by_name[name] - if (comp["lang"] == "rust" and comp.get("root") == BACKEND_RUST_ROOT - and path.startswith(_SHARED_BACKEND_DIRS)): + if ( + comp["lang"] == "rust" + and comp.get("root") == BACKEND_RUST_ROOT + and path.startswith(_SHARED_BACKEND_DIRS) + ): fanout_lint = True # a shared lib/plugin changed → re-lint dependents elif path.startswith(BACKEND_RUST_ROOT + "/") and path.endswith(_SHARED_RUST_SUFFIXES): fanout_lint = True # workspace-level Rust file (clippy.toml, Cargo.*, …) @@ -102,13 +104,12 @@ def changed_components(compare_branch: str, components: list[dict]) -> dict[str, if lang == "rust": is_backend = comp.get("root") == BACKEND_RUST_ROOT # cover=False in the registry ⇒ plain tests + lint still run on - # change, but no Cobertura is produced or gated (api-gateway — no - # unit tests yet; 0% would hard-fail the overall gate). + # change, but no Cobertura is produced or gated (authenticator — no + # in-process tests yet; 0% would hard-fail the overall gate). cover = name in changed and comp.get("cover", True) lint = name in changed or (fanout_lint and is_backend) if lint or cover: - result["rust"].append(_matrix_entry( - comp, lint=lint, cover=cover, test=name in changed)) + result["rust"].append(_matrix_entry(comp, lint=lint, cover=cover, test=name in changed)) elif name in changed: # dotnet / python: in the matrix iff changed result[lang].append(_matrix_entry(comp)) return result @@ -120,8 +121,7 @@ def all_components(components: list[dict]) -> dict[str, list]: result: dict[str, list] = {lang: [] for lang in ("rust", "dotnet", "python")} for comp in components: if comp["lang"] == "rust": - result["rust"].append(_matrix_entry( - comp, lint=True, cover=comp.get("cover", True), test=True)) + result["rust"].append(_matrix_entry(comp, lint=True, cover=comp.get("cover", True), test=True)) else: result[comp["lang"]].append(_matrix_entry(comp)) return result @@ -129,11 +129,10 @@ def all_components(components: list[dict]) -> dict[str, list]: def main() -> int: ap = argparse.ArgumentParser(description="Emit the CI component matrix as JSON.") - ap.add_argument("--all", action="store_true", - help="emit ALL components, ignoring the diff (manual full run)") + ap.add_argument("--all", action="store_true", help="emit ALL components, ignoring the diff (manual full run)") args = ap.parse_args() matrix = all_components(COMPONENTS) if args.all else changed_components(COMPARE_BRANCH, COMPONENTS) - print(json.dumps(matrix)) + print(json.dumps(matrix)) # noqa: T201 — this IS the script's CI output return 0 diff --git a/scripts/ci/components.py b/scripts/ci/components.py index e94246c5f..20500fd23 100755 --- a/scripts/ci/components.py +++ b/scripts/ci/components.py @@ -27,7 +27,7 @@ # Rust: `cargo llvm-cov --package ` run in . Each --package # report includes cross-crate files and the gate merges all reports (max # hits/line), so a lib's coverage reflects tests in other crates too, not - # just its own. NB: api-gateway's cargo package is insight-api-gateway. + # just its own. { "name": "insight-clickhouse", "lang": "rust", @@ -35,13 +35,6 @@ "package": "insight-clickhouse", "paths": ["src/backend/libs/insight-clickhouse"], }, - { - "name": "oidc-authn-plugin", - "lang": "rust", - "root": "src/backend", - "package": "oidc-authn-plugin", - "paths": ["src/backend/plugins/oidc-authn-plugin"], - }, { "name": "analytics", "lang": "rust", @@ -60,22 +53,6 @@ "cover_ignore_regex": "src/backend/libs/", "paths": ["src/backend/services/analytics"], }, - # cover=False: the gateway has no unit tests yet (its behavior is covered - # by the e2e suite), so a coverage report would gate it at 0% the moment - # any file under its paths changes. Tests + lint still run; re-enable - # coverage when unit tests land. Mirrors the identity decision below. - { - "name": "api-gateway", - "lang": "rust", - "root": "src/backend", - "package": "insight-api-gateway", - "cover": False, - # When coverage is re-enabled: scope out linked dependency crates - # (oidc-authn-plugin, libs) — they self-report in their own jobs, and - # zero-hit dependency files would gate THOSE components at 0%. - "cover_ignore_regex": "src/backend/(libs|plugins)/", - "paths": ["src/backend/services/api-gateway"], - }, # 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 @@ -99,7 +76,7 @@ "package": "routegen", "paths": ["src/backend/tools/routegen"], }, - # cover=False (mirrors api-gateway): the authenticator's security-critical + # cover=False (mirrors identity): the authenticator's security-critical # flow (OIDC login, sessions, cookie->JWT exchange) is proven by the e2e # login-loop, which drives the server as a SEPARATE process — so it can't # feed `cargo llvm-cov` (that instruments the test binary, not a spawned diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 0581f81f3..0ca205df6 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -81,6 +81,7 @@ dependencies = [ "cf-gears-authz-resolver", "cf-gears-gear-orchestrator", "cf-gears-grpc-hub", + "cf-gears-oidc-authn-plugin", "cf-gears-single-tenant-tr-plugin", "cf-gears-static-authz-plugin", "cf-gears-tenant-resolver", @@ -95,7 +96,7 @@ dependencies = [ "futures-util", "insight-clickhouse", "redis", - "reqwest", + "reqwest 0.12.28", "sea-orm", "sea-orm-migration", "serde", @@ -305,7 +306,7 @@ dependencies = [ "p256 0.14.0", "rand 0.8.6", "redis", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_yaml", @@ -329,7 +330,7 @@ dependencies = [ "async-trait", "cf-gears-toolkit-canonical-errors", "jsonwebtoken", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -864,6 +865,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "cf-gears-oidc-authn-plugin" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d8cf0412b3bab6cb36135d39b69de4d87aa5a92d3dcb1b944f39c1d5fd535" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "cf-gears-authn-resolver-sdk", + "cf-gears-toolkit", + "cf-gears-toolkit-macros", + "cf-gears-toolkit-security", + "cf-gears-types-registry-sdk", + "dashmap", + "httpdate", + "humantime", + "inventory", + "jsonwebtoken", + "opentelemetry", + "parking_lot", + "rand 0.10.1", + "regex", + "reqwest 0.13.4", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "cf-gears-single-tenant-tr-plugin" version = "0.1.23" @@ -1024,33 +1059,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "cf-gears-toolkit-auth" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32deb3072cc84986d6203b133d5c1f196f6ea5a3c78bb5cb31f669ea64a26f10" -dependencies = [ - "arc-swap", - "async-trait", - "base64 0.22.1", - "cf-gears-toolkit-http", - "cf-gears-toolkit-utils", - "http", - "jsonwebtoken", - "rand 0.10.1", - "serde", - "serde_json", - "thiserror 2.0.18", - "time", - "tokio", - "tokio-util", - "tower", - "tracing", - "url", - "uuid", - "zeroize", -] - [[package]] name = "cf-gears-toolkit-canonical-errors" version = "0.7.4" @@ -2152,7 +2160,7 @@ dependencies = [ "base64 0.22.1", "jsonwebtoken", "rand 0.8.6", - "reqwest", + "reqwest 0.12.28", "rsa", "serde", "serde_json", @@ -3016,6 +3024,7 @@ dependencies = [ "cf-gears-authz-resolver", "cf-gears-gear-orchestrator", "cf-gears-grpc-hub", + "cf-gears-oidc-authn-plugin", "cf-gears-single-tenant-tr-plugin", "cf-gears-static-authz-plugin", "cf-gears-tenant-resolver", @@ -3094,33 +3103,6 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "insight-api-gateway" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum", - "cf-gears-api-gateway", - "cf-gears-authn-resolver", - "cf-gears-authz-resolver", - "cf-gears-gear-orchestrator", - "cf-gears-grpc-hub", - "cf-gears-single-tenant-tr-plugin", - "cf-gears-static-authz-plugin", - "cf-gears-tenant-resolver", - "cf-gears-toolkit", - "cf-gears-toolkit-canonical-errors", - "cf-gears-types-registry", - "clap", - "oidc-authn-plugin", - "reqwest", - "serde", - "tokio", - "tower", - "tracing", -] - [[package]] name = "insight-clickhouse" version = "0.1.0" @@ -3194,6 +3176,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3664,7 +3695,7 @@ dependencies = [ "getrandom 0.2.17", "http", "rand 0.8.6", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -3673,25 +3704,6 @@ dependencies = [ "url", ] -[[package]] -name = "oidc-authn-plugin" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "cf-gears-authn-resolver-sdk", - "cf-gears-toolkit", - "cf-gears-toolkit-auth", - "cf-gears-toolkit-security", - "cf-gears-types-registry-sdk", - "secrecy", - "serde", - "serde_json", - "thiserror 2.0.18", - "tracing", - "uuid", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3802,7 +3814,7 @@ dependencies = [ "bytes", "http", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -3817,7 +3829,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tokio", "tonic", @@ -4456,6 +4468,7 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.2", "lru-slab", @@ -4781,6 +4794,50 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -4993,6 +5050,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -5628,6 +5712,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -6864,6 +6958,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6896,6 +7003,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 13ce701d2..cfb92dc1a 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -2,8 +2,6 @@ members = [ "libs/insight-clickhouse", "libs/authenticator-sdk", - "plugins/oidc-authn-plugin", - "services/api-gateway", "services/analytics", "services/identity-resolution", "services/authenticator", @@ -107,6 +105,9 @@ types-registry-sdk = { package = "cf-gears-types-registry-sdk", version = "0.3.0 api_gateway = { package = "cf-gears-api-gateway", version = "0.2.9" } authn-resolver = { package = "cf-gears-authn-resolver", version = "0.2.18" } authz-resolver = { package = "cf-gears-authz-resolver", version = "0.1.25" } +# Downstream gateway-JWT verification (JWKS + configurable claim mapping). Was a +# local fork under plugins/; now the upstream published crate. +oidc-authn-plugin = { package = "cf-gears-oidc-authn-plugin", version = "0.1.1" } tenant-resolver = { package = "cf-gears-tenant-resolver", version = "0.1.22" } static-authz-plugin = { package = "cf-gears-static-authz-plugin", version = "0.1.21" } single-tenant-tr-plugin = { package = "cf-gears-single-tenant-tr-plugin", version = "0.1.23" } @@ -121,6 +122,6 @@ figment = { version = "0.10", features = ["yaml", "env"] } insight-clickhouse = { path = "libs/insight-clickhouse" } authenticator-sdk = { path = "libs/authenticator-sdk" } -# CI rebuild marker — bumped to retrigger the api-gateway + analytics -# image builds on the cf → gears crate migration (2026-06-12). +# CI rebuild marker — bumped to retrigger the analytics +# image build on the cf → gears crate migration (2026-06-12). diff --git a/src/backend/plugins/oidc-authn-plugin/Cargo.toml b/src/backend/plugins/oidc-authn-plugin/Cargo.toml deleted file mode 100644 index d7a7862ff..000000000 --- a/src/backend/plugins/oidc-authn-plugin/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "oidc-authn-plugin" -version = "0.1.0" -description = "OIDC/JWT authentication plugin for gears-rust authn-resolver" -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -rust-version.workspace = true - -[lints] -workspace = true - -[dependencies] -# gears-rust -toolkit = { workspace = true } -toolkit-auth = { workspace = true } -toolkit-security = { workspace = true } -authn-resolver-sdk = { workspace = true } -types-registry-sdk = { workspace = true } - -# Async -async-trait = { workspace = true } - -# Serialization -serde = { workspace = true } -serde_json = { workspace = true } - -# Security -secrecy = { workspace = true } - -# IDs -uuid = { workspace = true } - -# Logging -tracing = { workspace = true } - -# Error handling -anyhow = { workspace = true } -thiserror = { workspace = true } diff --git a/src/backend/plugins/oidc-authn-plugin/README.md b/src/backend/plugins/oidc-authn-plugin/README.md deleted file mode 100644 index ab787f857..000000000 --- a/src/backend/plugins/oidc-authn-plugin/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# OIDC AuthN Plugin - -A cyberfabric `authn-resolver` plugin that validates JWT bearer tokens against an OIDC provider (Okta, Keycloak, Auth0, or any OIDC-compliant IdP). - -## How it works - -1. On startup, fetches JWKS keys from the provider's keys endpoint -2. On each request, validates the JWT signature against cached keys -3. Validates standard claims (issuer, audience, expiry, not-before) -4. Extracts `sub`, scopes, and tenant ID from claims -5. Builds a `SecurityContext` and returns it to the authn-resolver gateway -6. Background task refreshes JWKS keys periodically - -## Configuration - -```yaml -gears: - oidc-authn-plugin: - config: - vendor: "hyperspot" - priority: 50 - issuer_url: "https://dev-12345.okta.com/oauth2/default" - audience: "api://insight" - # jwks_url: "" # Override if JWKS URL differs from {issuer}/v1/keys - jwks_refresh_interval_seconds: 300 # 5 minutes - tenant_claim: "tenant_id" # JWT claim containing tenant UUID - subject_type: "user" - leeway_seconds: 60 # Clock skew tolerance -``` - -### Configuration reference - -| Field | Required | Default | Description | -|-------|----------|---------|-------------| -| `vendor` | No | `hyperspot` | Vendor name for GTS plugin registration | -| `priority` | No | `50` | Plugin priority (lower = higher) | -| `issuer_url` | **Yes** | — | OIDC issuer URL. Used for `iss` claim validation and JWKS URL derivation | -| `audience` | No | — | Expected `aud` claim. If empty, audience is not validated | -| `jwks_url` | No | `{issuer_url}/v1/keys` | JWKS endpoint override | -| `jwks_refresh_interval_seconds` | No | `300` | How often to refresh JWKS keys | -| `tenant_claim` | No | `tenant_id` | JWT claim name containing the tenant UUID | -| `subject_type` | No | `user` | Subject type in `SecurityContext` | -| `leeway_seconds` | No | `60` | Clock skew tolerance for exp/nbf validation | - -### Environment variable overrides - -```bash -export APP__gears__oidc-authn-plugin__config__issuer_url=https://dev-12345.okta.com/oauth2/default -export APP__gears__oidc-authn-plugin__config__audience=api://insight -``` - -## Claim mapping - -| JWT claim | SecurityContext field | Notes | -|-----------|---------------------|-------| -| `sub` | `subject_id` | Hashed to UUID v5 (OIDC subs are often not UUIDs) | -| `scp` (array) or `scope` (string) | `token_scopes` | Okta uses `scp`, standard OIDC uses `scope`. Empty if neither present (authz layer decides) | -| `{tenant_claim}` | `subject_tenant_id` | Configurable claim name. Parsed as UUID. Falls back to nil UUID | - -## Provider setup - -### Okta - -1. Create an API application in Okta Admin Console -2. Note the **Issuer URI**: `https://dev-XXXXX.okta.com/oauth2/default` -3. Set the **Audience** in your authorization server settings -4. JWKS URL is auto-derived: `{issuer}/v1/keys` -5. For tenant isolation, add a custom claim `tenant_id` to the authorization server - -### Keycloak - -1. Create a realm and client -2. Issuer URI: `https://keycloak.example.com/realms/{realm}` -3. JWKS URL: `{issuer}/protocol/openid-connect/certs` — set `jwks_url` explicitly -4. Audience: the client ID - -### Auth0 - -1. Create an API in Auth0 Dashboard -2. Issuer URI: `https://{tenant}.auth0.com/` -3. JWKS URL: `{issuer}/.well-known/jwks.json` — set `jwks_url` explicitly -4. Audience: the API identifier - -## Limitations - -- **Client credentials flow** is not supported. Use `static-authn-plugin` for S2S authentication. -- **Token refresh** is not handled by this plugin — clients must refresh tokens with the IdP directly. -- **Custom claim extraction** is limited to the configured `tenant_claim`. Additional claims require code changes. - -## Architecture - -This plugin implements the `AuthNResolverPluginClient` trait from `authn-resolver-sdk`. It is discovered by the `authn-resolver` gateway module via the GTS types-registry at runtime. - -```text -Request with Bearer token - → API Gateway extracts token - → authn-resolver delegates to this plugin - → Plugin validates JWT (signature + claims) - → Returns SecurityContext - → API Gateway injects SecurityContext into request -``` - -Uses `modkit-auth` crate for: -- `JwksKeyProvider` — JWKS key fetching and caching -- `validate_claims` — standard JWT claim validation diff --git a/src/backend/plugins/oidc-authn-plugin/src/config.rs b/src/backend/plugins/oidc-authn-plugin/src/config.rs deleted file mode 100644 index f81ce16d5..000000000 --- a/src/backend/plugins/oidc-authn-plugin/src/config.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Configuration for the OIDC `AuthN` plugin. - -use serde::Deserialize; - -/// OIDC plugin configuration. -/// -/// # Example (YAML) -/// -/// ```yaml -/// modules: -/// oidc-authn-plugin: -/// config: -/// vendor: "hyperspot" -/// priority: 50 -/// issuer_url: "https://dev-12345.okta.com/oauth2/default" -/// audience: "api://insight" -/// jwks_refresh_interval_seconds: 300 -/// tenant_claim: "tenant_id" -/// subject_type: "user" -/// ``` -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct OidcAuthnPluginConfig { - /// Vendor name for GTS instance registration. - pub vendor: String, - - /// Plugin priority (lower = higher priority). - pub priority: i16, - - /// OIDC issuer URL (e.g., `https://dev-12345.okta.com/oauth2/default`). - /// Used for `iss` claim validation and default JWKS URL derivation. - pub issuer_url: String, - - /// Expected audience claim (`aud`). If empty, audience is not validated. - pub audience: String, - - /// JWKS endpoint URL override. If empty, defaults to `{issuer_url}/v1/keys` (Okta convention). - /// **Must be set explicitly** for non-Okta providers: - /// - Keycloak: `{issuer}/protocol/openid-connect/certs` - /// - Auth0: `{issuer}/.well-known/jwks.json` - pub jwks_url: String, - - /// JWKS key refresh interval in seconds. - pub jwks_refresh_interval_seconds: u64, - - /// JWT claim name containing the tenant ID. - /// Okta custom claims or standard claims can be used. - /// If the claim is missing, the subject's home tenant defaults to a nil UUID. - pub tenant_claim: String, - - /// If `true`, reject tokens that are missing the tenant claim or have - /// an unparseable tenant UUID. When `false` (default), a missing tenant - /// claim logs a warning and defaults to nil UUID. - pub require_tenant_claim: bool, - - /// Subject type passed to `SecurityContext` (e.g., "user", "service"). - pub subject_type: String, - - /// Leeway in seconds for token expiry validation. - pub leeway_seconds: i64, -} - -impl Default for OidcAuthnPluginConfig { - fn default() -> Self { - Self { - vendor: "hyperspot".to_owned(), - priority: 50, - issuer_url: String::new(), - audience: String::new(), - jwks_url: String::new(), - jwks_refresh_interval_seconds: 300, - tenant_claim: "tenant_id".to_owned(), - require_tenant_claim: false, - subject_type: "user".to_owned(), - leeway_seconds: 60, - } - } -} - -impl OidcAuthnPluginConfig { - /// Returns the effective JWKS URL. - /// If `jwks_url` is set, use it. Otherwise derive from `issuer_url`. - #[must_use] - pub fn effective_jwks_url(&self) -> String { - if self.jwks_url.is_empty() { - format!("{}/v1/keys", self.issuer_url.trim_end_matches('/')) - } else { - self.jwks_url.clone() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_config() { - let cfg = OidcAuthnPluginConfig::default(); - assert_eq!(cfg.vendor, "hyperspot"); - assert_eq!(cfg.priority, 50); - assert_eq!(cfg.tenant_claim, "tenant_id"); - assert_eq!(cfg.leeway_seconds, 60); - } - - #[test] - fn jwks_url_derived_from_issuer() { - let cfg = OidcAuthnPluginConfig { - issuer_url: "https://dev-123.okta.com/oauth2/default".to_owned(), - ..Default::default() - }; - assert_eq!( - cfg.effective_jwks_url(), - "https://dev-123.okta.com/oauth2/default/v1/keys" - ); - } - - #[test] - fn jwks_url_override() { - let cfg = OidcAuthnPluginConfig { - issuer_url: "https://okta.example.com".to_owned(), - jwks_url: "https://custom.example.com/.well-known/jwks.json".to_owned(), - ..Default::default() - }; - assert_eq!( - cfg.effective_jwks_url(), - "https://custom.example.com/.well-known/jwks.json" - ); - } - - #[test] - fn issuer_trailing_slash_stripped() { - let cfg = OidcAuthnPluginConfig { - issuer_url: "https://okta.example.com/".to_owned(), - ..Default::default() - }; - assert_eq!(cfg.effective_jwks_url(), "https://okta.example.com/v1/keys"); - } -} diff --git a/src/backend/plugins/oidc-authn-plugin/src/domain/client.rs b/src/backend/plugins/oidc-authn-plugin/src/domain/client.rs deleted file mode 100644 index 6c1d62d02..000000000 --- a/src/backend/plugins/oidc-authn-plugin/src/domain/client.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! `AuthNResolverPluginClient` implementation for the OIDC plugin. - -use std::sync::Arc; - -use async_trait::async_trait; -use authn_resolver_sdk::error::AuthNResolverError; -use authn_resolver_sdk::models::{AuthenticationResult, ClientCredentialsRequest}; -use authn_resolver_sdk::plugin_api::AuthNResolverPluginClient; - -use super::service::OidcService; - -/// Adapter implementing `AuthNResolverPluginClient` using the OIDC service. -pub struct OidcAuthnClient { - service: Arc, -} - -impl OidcAuthnClient { - #[must_use] - pub fn new(service: Arc) -> Self { - Self { service } - } -} - -#[async_trait] -impl AuthNResolverPluginClient for OidcAuthnClient { - async fn authenticate( - &self, - bearer_token: &str, - ) -> Result { - let security_context = self - .service - .validate_token(bearer_token) - .await - .map_err(|e| { - tracing::warn!(error = %e, "OIDC token validation failed"); - AuthNResolverError::Unauthorized(e.to_string()) - })?; - - Ok(AuthenticationResult { security_context }) - } - - async fn exchange_client_credentials( - &self, - _request: &ClientCredentialsRequest, - ) -> Result { - // Client credentials flow not implemented for OIDC plugin. - // S2S communication should use the static-authn-plugin or - // a dedicated service account token. - Err(AuthNResolverError::Unauthorized( - "client_credentials flow not supported by OIDC plugin — use static-authn-plugin for S2S".to_owned(), - )) - } -} diff --git a/src/backend/plugins/oidc-authn-plugin/src/domain/mod.rs b/src/backend/plugins/oidc-authn-plugin/src/domain/mod.rs deleted file mode 100644 index 431311a87..000000000 --- a/src/backend/plugins/oidc-authn-plugin/src/domain/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod client; -pub mod service; diff --git a/src/backend/plugins/oidc-authn-plugin/src/domain/service.rs b/src/backend/plugins/oidc-authn-plugin/src/domain/service.rs deleted file mode 100644 index 74a90a094..000000000 --- a/src/backend/plugins/oidc-authn-plugin/src/domain/service.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! OIDC token validation service. -//! -//! Uses `toolkit-auth` `JwksKeyProvider` for JWT signature validation -//! and `validate_claims` for standard claim checks. - -use secrecy::SecretString; -use std::sync::Arc; -use toolkit_auth::traits::KeyProvider; -use toolkit_auth::{JwksKeyProvider, ValidationConfig, validate_claims}; -use toolkit_security::SecurityContext; -use uuid::Uuid; - -use crate::config::OidcAuthnPluginConfig; - -/// Errors from OIDC token validation. -#[derive(Debug, thiserror::Error)] -pub enum OidcError { - #[error("token signature validation failed: {0}")] - SignatureInvalid(String), - - #[error("token claims validation failed: {0}")] - ClaimsInvalid(String), - - #[error("missing required claim: {0}")] - MissingClaim(String), - - #[error("invalid claim format: {field} — {reason}")] - InvalidClaimFormat { field: String, reason: String }, -} - -/// OIDC token validation service. -/// -/// Validates JWT bearer tokens using JWKS key discovery and -/// builds a `SecurityContext` from the validated claims. -pub struct OidcService { - key_provider: Arc, - validation_config: ValidationConfig, - issuer_url: String, - tenant_claim: String, - require_tenant_claim: bool, - subject_type: String, -} - -impl OidcService { - /// Creates a new OIDC service from plugin configuration. - #[must_use] - pub fn new(config: &OidcAuthnPluginConfig, key_provider: Arc) -> Self { - let mut allowed_issuers = Vec::new(); - if !config.issuer_url.is_empty() { - allowed_issuers.push(config.issuer_url.clone()); - } - - let mut allowed_audiences = Vec::new(); - if !config.audience.is_empty() { - allowed_audiences.push(config.audience.clone()); - } - - let validation_config = ValidationConfig { - allowed_issuers, - allowed_audiences, - leeway_seconds: config.leeway_seconds, - require_exp: true, - }; - - Self { - key_provider, - validation_config, - issuer_url: config.issuer_url.clone(), - tenant_claim: config.tenant_claim.clone(), - require_tenant_claim: config.require_tenant_claim, - subject_type: config.subject_type.clone(), - } - } - - /// Validates a JWT bearer token and returns a `SecurityContext`. - /// - /// # Flow - /// - /// 1. Validate JWT signature using JWKS keys - /// 2. Validate standard claims (iss, aud, exp, nbf) - /// 3. Extract subject (`sub` claim) → `subject_id` - /// 4. Extract tenant (`tenant_claim`) → `subject_tenant_id` - /// 5. Extract scopes (`scp` or `scope` claim) → `token_scopes` - /// 6. Build `SecurityContext` - /// - /// # Errors - /// - /// Returns `OidcError` if token is invalid, expired, or missing required claims. - pub async fn validate_token(&self, token: &str) -> Result { - // 1. Validate signature and decode claims - let (_header, claims) = self - .key_provider - .validate_and_decode(token) - .await - .map_err(|e| OidcError::SignatureInvalid(e.to_string()))?; - - // 2. Validate standard claims - validate_claims(&claims, &self.validation_config) - .map_err(|e| OidcError::ClaimsInvalid(e.to_string()))?; - - // 3. Extract subject_id from `sub` claim - let sub_str = claims - .get("sub") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| OidcError::MissingClaim("sub".to_owned()))?; - - // OIDC `sub` is often not a UUID — use a deterministic UUID v5 from issuer+sub - // to prevent collisions across different IdPs - let subject_id = uuid_from_issuer_sub(&self.issuer_url, sub_str); - - // 4. Extract tenant_id from configured claim - let subject_tenant_id = match claims - .get(&self.tenant_claim) - .and_then(serde_json::Value::as_str) - { - Some(s) if Uuid::parse_str(s).is_ok() => { - // Safe: just checked is_ok - Uuid::parse_str(s).unwrap_or_default() - } - Some(s) => { - tracing::warn!( - claim = %self.tenant_claim, - value = %s, - "tenant claim is not a valid UUID" - ); - if self.require_tenant_claim { - return Err(OidcError::InvalidClaimFormat { - field: self.tenant_claim.clone(), - reason: format!("not a valid UUID: {s}"), - }); - } - Uuid::default() - } - None => { - tracing::warn!( - claim = %self.tenant_claim, - "tenant claim missing from JWT" - ); - if self.require_tenant_claim { - return Err(OidcError::MissingClaim(self.tenant_claim.clone())); - } - Uuid::default() - } - }; - - // 5. Extract scopes from `scp` (Okta) or `scope` (standard) claim - let token_scopes = extract_scopes(&claims); - - // 6. Build SecurityContext - let ctx = SecurityContext::builder() - .subject_id(subject_id) - .subject_type(&self.subject_type) - .subject_tenant_id(subject_tenant_id) - .token_scopes(token_scopes) - .bearer_token(SecretString::from(token.to_owned())) - .build() - .map_err(|e| OidcError::InvalidClaimFormat { - field: "security_context".to_owned(), - reason: e.to_string(), - })?; - - Ok(ctx) - } -} - -/// Creates a deterministic UUID v5 from an OIDC issuer + `sub` claim. -/// Includes issuer to prevent collisions when the same `sub` appears across different providers. -fn uuid_from_issuer_sub(issuer: &str, sub: &str) -> Uuid { - let input = format!("{issuer}#{sub}"); - Uuid::new_v5(&Uuid::NAMESPACE_URL, input.as_bytes()) -} - -/// Extracts scopes from JWT claims. -/// Supports Okta-style `scp` (array) and standard `scope` (space-delimited string). -fn extract_scopes(claims: &serde_json::Value) -> Vec { - // Try `scp` first (Okta style — array of strings) - if let Some(scp) = claims.get("scp").and_then(serde_json::Value::as_array) { - return scp - .iter() - .filter_map(serde_json::Value::as_str) - .map(String::from) - .collect(); - } - - // Try `scope` (standard — space-delimited string) - if let Some(scope) = claims.get("scope").and_then(serde_json::Value::as_str) { - return scope.split_whitespace().map(String::from).collect(); - } - - // No scopes claim present — return empty (authz layer decides access) - Vec::new() -} - -#[cfg(test)] -mod tests { - use super::*; - - const ISSUER_A: &str = "https://okta-a.example.com"; - const ISSUER_B: &str = "https://okta-b.example.com"; - - #[test] - fn uuid_from_issuer_sub_is_deterministic() { - let id1 = uuid_from_issuer_sub(ISSUER_A, "user123"); - let id2 = uuid_from_issuer_sub(ISSUER_A, "user123"); - assert_eq!(id1, id2); - } - - #[test] - fn uuid_from_issuer_sub_different_subs_differ() { - let id1 = uuid_from_issuer_sub(ISSUER_A, "user123"); - let id2 = uuid_from_issuer_sub(ISSUER_A, "user456"); - assert_ne!(id1, id2); - } - - #[test] - fn uuid_from_issuer_sub_different_issuers_differ() { - // Same sub across different IdPs must NOT collide - let id1 = uuid_from_issuer_sub(ISSUER_A, "user123"); - let id2 = uuid_from_issuer_sub(ISSUER_B, "user123"); - assert_ne!(id1, id2); - } - - #[test] - fn uuid_from_issuer_sub_empty_sub() { - // Should not panic — empty sub is technically valid OIDC - let id = uuid_from_issuer_sub(ISSUER_A, ""); - assert_ne!(id, Uuid::default()); - } - - #[test] - fn extract_scopes_okta_style() { - let claims = serde_json::json!({ - "scp": ["openid", "profile", "email"] - }); - let scopes = extract_scopes(&claims); - assert_eq!(scopes, vec!["openid", "profile", "email"]); - } - - #[test] - fn extract_scopes_standard_style() { - let claims = serde_json::json!({ - "scope": "openid profile email" - }); - let scopes = extract_scopes(&claims); - assert_eq!(scopes, vec!["openid", "profile", "email"]); - } - - #[test] - fn extract_scopes_none_returns_empty() { - let claims = serde_json::json!({}); - let scopes = extract_scopes(&claims); - assert!( - scopes.is_empty(), - "no scope claims should return empty vec, not wildcard" - ); - } - - #[test] - fn extract_scopes_scp_takes_priority() { - let claims = serde_json::json!({ - "scp": ["admin"], - "scope": "read write" - }); - let scopes = extract_scopes(&claims); - assert_eq!(scopes, vec!["admin"]); - } - - #[test] - fn extract_scopes_empty_scope_string_returns_empty() { - let claims = serde_json::json!({ - "scope": "" - }); - let scopes = extract_scopes(&claims); - assert!(scopes.is_empty()); - } - - #[test] - fn extract_scopes_scp_with_non_string_elements_filtered() { - let claims = serde_json::json!({ - "scp": ["openid", 123, true, "email"] - }); - let scopes = extract_scopes(&claims); - assert_eq!(scopes, vec!["openid", "email"]); - } -} diff --git a/src/backend/plugins/oidc-authn-plugin/src/lib.rs b/src/backend/plugins/oidc-authn-plugin/src/lib.rs deleted file mode 100644 index 8bf644b7c..000000000 --- a/src/backend/plugins/oidc-authn-plugin/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! OIDC/JWT authentication plugin for cyberfabric `authn-resolver`. -//! -//! Validates JWT bearer tokens against an OIDC provider (Okta, Keycloak, Auth0, etc.) -//! using JWKS key discovery and standard JWT claims validation. -//! -//! Implements `AuthNResolverPluginClient` trait from `authn-resolver-sdk`. - -pub mod config; -pub mod domain; -pub mod module; - -pub use module::OidcAuthnPlugin; diff --git a/src/backend/plugins/oidc-authn-plugin/src/module.rs b/src/backend/plugins/oidc-authn-plugin/src/module.rs deleted file mode 100644 index 17ec6c5fd..000000000 --- a/src/backend/plugins/oidc-authn-plugin/src/module.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! OIDC `AuthN` plugin module registration. -//! -//! Registers with the cyberfabric runtime via the `#[toolkit::gear]` macro. -//! Discovered by `authn-resolver` gateway via the GTS types-registry. - -use std::sync::{Arc, OnceLock}; -use std::time::Duration; - -use async_trait::async_trait; -use authn_resolver_sdk::{AuthNResolverPluginClient, AuthNResolverPluginSpecV1}; -use toolkit::Gear; -use toolkit::client_hub::ClientScope; -use toolkit::context::GearCtx; -use toolkit::gts::PluginV1; -use tracing::info; -use types_registry_sdk::{RegisterResult, TypesRegistryClient}; - -use crate::config::OidcAuthnPluginConfig; -use crate::domain::client::OidcAuthnClient; -use crate::domain::service::OidcService; - -/// OIDC authentication plugin module. -/// -/// Validates JWT bearer tokens against an OIDC provider (Okta, Keycloak, Auth0, etc.) -/// using JWKS key discovery. -#[toolkit::gear( - name = "oidc-authn-plugin", - deps = ["types-registry"] -)] -pub struct OidcAuthnPlugin { - service: OnceLock>, -} - -impl Default for OidcAuthnPlugin { - fn default() -> Self { - Self { - service: OnceLock::new(), - } - } -} - -#[async_trait] -impl Gear for OidcAuthnPlugin { - async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { - let config: OidcAuthnPluginConfig = ctx.config()?; - - if config.issuer_url.is_empty() { - anyhow::bail!( - "oidc-authn-plugin: issuer_url is required. \ - Set gears.oidc-authn-plugin.config.issuer_url in your config." - ); - } - - if config.jwks_refresh_interval_seconds == 0 { - anyhow::bail!("oidc-authn-plugin: jwks_refresh_interval_seconds must be > 0"); - } - - if config.leeway_seconds < 0 { - anyhow::bail!( - "oidc-authn-plugin: leeway_seconds must be >= 0, got {}", - config.leeway_seconds - ); - } - - info!( - issuer = %config.issuer_url, - audience = %config.audience, - jwks_url = %config.effective_jwks_url(), - "initializing OIDC authn plugin" - ); - - // Create JWKS key provider using toolkit-auth - let key_provider = Arc::new( - toolkit_auth::JwksKeyProvider::new(config.effective_jwks_url())? - .with_refresh_interval(Duration::from_secs(config.jwks_refresh_interval_seconds)), - ); - - // Initial key fetch — log warning if IdP is unreachable, don't crash - match toolkit_auth::traits::KeyProvider::refresh_keys(key_provider.as_ref()).await { - Ok(()) => info!("JWKS keys loaded successfully"), - Err(e) => tracing::warn!( - error = %e, - "initial JWKS key fetch failed — will retry in background. \ - Auth requests will fail until keys are loaded." - ), - } - - // Create service (not stored in OnceLock yet — registration may fail) - let service = Arc::new(OidcService::new(&config, key_provider)); - - // Generate plugin instance ID - let instance_id = AuthNResolverPluginSpecV1::gts_make_instance_id( - "insight.core.oidc_authn_resolver.plugin.v1", - ); - - // Register plugin instance in types-registry (fallible — do before OnceLock) - let registry = ctx.client_hub().get::()?; - let instance = PluginV1:: { - id: instance_id.clone(), - vendor: config.vendor.clone(), - priority: config.priority, - properties: AuthNResolverPluginSpecV1, - }; - let instance_json = serde_json::to_value(&instance)?; - let results = registry.register(vec![instance_json]).await?; - RegisterResult::ensure_all_ok(&results)?; - - // Registration succeeded — now commit to OnceLock (irreversible) - self.service - .set(service.clone()) - .map_err(|_| anyhow::anyhow!("{} module already initialized", Self::MODULE_NAME))?; - - // Register scoped client in ClientHub - let api: Arc = Arc::new(OidcAuthnClient::new(service)); - ctx.client_hub() - .register_scoped::( - ClientScope::gts_id(&instance_id), - api, - ); - - info!(instance_id = %instance_id, "OIDC authn plugin initialized"); - Ok(()) - } -} diff --git a/src/backend/services/LOCAL_DEV.md b/src/backend/services/LOCAL_DEV.md deleted file mode 100644 index 7c0038c7e..000000000 --- a/src/backend/services/LOCAL_DEV.md +++ /dev/null @@ -1,197 +0,0 @@ -# Local Kubernetes Development - -Guide for running Insight backend services on a local Kubernetes cluster. Updated as new services are added. - -## Prerequisites - -- **Docker Desktop** or **OrbStack** (macOS) — container runtime -- **Local K8s** — one of: - - OrbStack (built-in K8s, recommended for macOS) - - minikube (`brew install minikube`) - - kind (`brew install kind`) - - Docker Desktop Kubernetes (Settings → Kubernetes → Enable) -- **Helm 3** — `brew install helm` -- **kubectl** — `brew install kubectl` -- **Rust 1.92+** — `rustup update stable` -- **protoc** — `brew install protobuf` (required for gRPC code generation) - -## Cluster setup - -### OrbStack (recommended) - -OrbStack includes a single-node K8s cluster. Enable it in OrbStack settings. - -```bash -kubectl cluster-info -kubectl get nodes -``` - -### minikube - -```bash -minikube start --memory=4096 --cpus=4 -``` - -### kind - -```bash -kind create cluster --name insight -``` - -## Building images - -The Insight backend depends on cyberfabric-core via path dependencies. The Docker build context must include both repos. The expected directory layout: - -``` -cf/ -├── cyberfabric-core/ # cyberfabric-core repo -└── insight/ # this repo -``` - -### Build API Gateway image - -From the `cf/` parent directory: - -```bash -cd /path/to/cf - -docker build \ - -f insight/src/backend/services/api-gateway/Dockerfile \ - -t insight-api-gateway:dev \ - . -``` - -### Load image into cluster - -```bash -# OrbStack — local images are already available, no extra step. - -# minikube: -minikube image load insight-api-gateway:dev - -# kind: -kind load docker-image insight-api-gateway:dev --name insight -``` - -## Services - -### API Gateway - -The entry point for all backend requests. Validates JWT tokens via OIDC and routes to service modules. - -#### Deploy without auth (quickstart) - -No OIDC provider needed. All requests get root access. - -```bash -cd insight/src/backend - -helm install insight-gw services/api-gateway/helm/ \ - --set image.repository=insight-api-gateway \ - --set image.tag=dev \ - --set image.pullPolicy=Never \ - --set authDisabled=true \ - --set ingress.enabled=false -``` - -Verify and access: - -```bash -kubectl get pods -l app.kubernetes.io/name=api-gateway -kubectl logs -l app.kubernetes.io/name=api-gateway -f - -# Port-forward to access locally -kubectl port-forward svc/insight-gw-api-gateway 8080:8080 -curl http://localhost:8080/api/v1/docs -``` - -#### Deploy with OIDC (Okta) - -Requires an Okta application. See `plugins/oidc-authn-plugin/README.md` for IdP setup. - -```bash -helm install insight-gw services/api-gateway/helm/ \ - --set image.repository=insight-api-gateway \ - --set image.tag=dev \ - --set image.pullPolicy=Never \ - --set oidc.issuerUrl=https://dev-12345.okta.com/oauth2/default \ - --set oidc.audience=api://insight \ - --set oidc.clientId=YOUR_FRONTEND_CLIENT_ID \ - --set oidc.redirectUri=http://localhost:3000/callback \ - --set ingress.enabled=false -``` - -Port-forward and test: - -```bash -kubectl port-forward svc/insight-gw-api-gateway 8080:8080 - -# Public endpoint (no auth): -curl http://localhost:8080/api/v1/auth/config - -# Authenticated endpoint: -TOKEN=$(curl -s -X POST https://dev-12345.okta.com/oauth2/default/v1/token \ - -d grant_type=client_credentials \ - -d client_id=YOUR_CLIENT_ID \ - -d client_secret=YOUR_CLIENT_SECRET \ - -d scope=openid | jq -r .access_token) - -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/docs -``` - -#### Uninstall - -```bash -helm uninstall insight-gw -``` - -## Running without Kubernetes - -For fast iteration, run the binary directly. No Docker or K8s needed. - -```bash -cd insight/src/backend - -# No auth (dev mode) -cargo run --bin insight-api-gateway -- run -c services/api-gateway/config/no-auth.yaml - -# With OIDC -OIDC_ISSUER_URL=https://dev-12345.okta.com/oauth2/default \ -OIDC_AUDIENCE=api://insight \ -OIDC_CLIENT_ID=your-frontend-client-id \ -OIDC_REDIRECT_URI=http://localhost:3000/callback \ -cargo run --bin insight-api-gateway -- run -c services/api-gateway/config/insight.yaml -``` - -## Troubleshooting - -### Build fails with "Could not find protoc" - -```bash -brew install protobuf -``` - -### Pod stuck in CrashLoopBackOff - -```bash -kubectl logs -l app.kubernetes.io/name=api-gateway --previous -``` - -Common causes: -- Missing OIDC env vars when `authDisabled=false` -- JWKS endpoint unreachable from inside cluster (DNS/firewall) -- Port conflict on 8080 - -### Cannot reach service from host - -```bash -kubectl get svc -kubectl port-forward svc/insight-gw-api-gateway 8080:8080 -``` - -### OIDC token rejected - -- Check issuer URL matches token's `iss` claim exactly (trailing slash matters) -- Check audience matches token's `aud` claim -- Check clock skew (`leeway_seconds` default is 60s) -- Check JWKS keys loaded: look for "OIDC authn plugin initialized" in logs diff --git a/src/backend/services/analytics/Cargo.toml b/src/backend/services/analytics/Cargo.toml index 15d81b833..d090f67a7 100644 --- a/src/backend/services/analytics/Cargo.toml +++ b/src/backend/services/analytics/Cargo.toml @@ -23,19 +23,21 @@ sea-orm = { workspace = true } sea-orm-migration = { workspace = true } # ToolKit host runtime. analytics runs as a gears-rust host: routes are # declared via `OperationBuilder` and served by the `api-gateway` system gear -# (the REST host) under `toolkit::bootstrap::run_server`. Auth is disabled on -# this host (the platform api-gateway is the sole authenticator; see -# config + `crate::gear`), so the host injects a single-tenant SecurityContext -# and a thin layer overrides the tenant from `X-Insight-Tenant-Id`. +# (the REST host) under `toolkit::bootstrap::run_server`. Auth is ENABLED: the +# `oidc-authn-plugin` (cf-gears-oidc-authn-plugin) verifies the ES256 gateway JWT +# against the authenticator's JWKS and maps its claims to a SecurityContext via +# configured claim_mapping (sub→subject_id, tenant_id→subject_tenant_id, +# roles→token_scopes) — no bespoke wrapper (NGINX_BFF R1 / G2). toolkit = { workspace = true, features = ["bootstrap"] } toolkit-security = { workspace = true } toolkit-canonical-errors = { workspace = true } -# System gears linked for the REST host + (disabled) auth pipeline. Discovered -# at link time via inventory; mirrors the api-gateway service's no-auth setup. +# System gears linked for the REST host + auth pipeline. Discovered at link time +# via inventory; mirrors the api-gateway service's authenticated gear set. api_gateway = { workspace = true } authn-resolver = { workspace = true } authz-resolver = { workspace = true } +oidc-authn-plugin = { workspace = true } tenant-resolver = { workspace = true } single-tenant-tr-plugin = { workspace = true } static-authz-plugin = { workspace = true } @@ -66,7 +68,6 @@ tracing-subscriber = { workspace = true } clap = { workspace = true } [dev-dependencies] -# `tower::ServiceExt::oneshot` for driving the Axum router in -# `api::tenant_resolution_tests` (Refs #522). Same dev-dep pattern as -# api-gateway's proxy tests. +# `tower::ServiceExt::oneshot` for driving the Axum router in the HTTP +# integration tests. Same dev-dep pattern as api-gateway's proxy tests. tower = { version = "0.5", features = ["util"] } diff --git a/src/backend/services/analytics/Dockerfile b/src/backend/services/analytics/Dockerfile index 15bc8101c..8656a3dc7 100644 --- a/src/backend/services/analytics/Dockerfile +++ b/src/backend/services/analytics/Dockerfile @@ -22,8 +22,6 @@ WORKDIR /build 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 plugins/oidc-authn-plugin/Cargo.toml ./plugins/oidc-authn-plugin/Cargo.toml -COPY services/api-gateway/Cargo.toml ./services/api-gateway/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 @@ -38,8 +36,6 @@ 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 plugins/oidc-authn-plugin/src && echo "" > plugins/oidc-authn-plugin/src/lib.rs && \ - mkdir -p services/api-gateway/src && echo "fn main() {}" > services/api-gateway/src/main.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 && \ @@ -65,7 +61,7 @@ FROM debian:bookworm-slim ARG TARGETARCH ARG WATCHEXEC_VERSION=2.3.2 -# Runtime deps + watchexec. See api-gateway/Dockerfile for rationale. +# Runtime deps + watchexec. RUN set -eux; \ case "${TARGETARCH:-amd64}" in \ amd64) WX_ARCH=x86_64-unknown-linux-musl ;; \ diff --git a/src/backend/services/analytics/config/insight.yaml b/src/backend/services/analytics/config/insight.yaml index 1af381fd2..e6c16e671 100644 --- a/src/backend/services/analytics/config/insight.yaml +++ b/src/backend/services/analytics/config/insight.yaml @@ -3,13 +3,17 @@ # Usage: analytics --config config/insight.yaml # analytics --config config/insight.yaml migrate # -# The `api-gateway` system gear is the REST host with auth DISABLED — the -# platform api-gateway is the sole authenticator and proxies to us, so this -# host injects a single-tenant SecurityContext (DEFAULT_TENANT_ID). The -# `analytics` gear then optionally overrides the tenant from the -# `X-Insight-Tenant-Id` header (see crate::auth). NO oidc plugin is registered. +# Auth is ENABLED on the api-gateway host (NGINX_BFF R1): the upstream +# `cf-gears-oidc-authn-plugin` verifies the ES256 gateway JWT (signature / iss / +# aud / exp) against the authenticator's JWKS — resolved via OIDC discovery +# (`{issuer}/.well-known/openid-configuration` -> `jwks_uri`) — and maps the +# signed claims straight into the request SecurityContext via `claim_mapping` +# (sub -> subject_id, tenant_id -> subject_tenant_id, sub_type -> subject_type, +# roles -> token_scopes). No bespoke wrapper, no `X-Tenant-ID` selector: the +# single signed `tenant_id` claim is the sole tenant authority. A request +# without a valid gateway JWT carrying a `tenant_id` gets 401. # -# Env overrides (prefix CHANGED from the old `ANALYTICS__*`): +# Env overrides (scalar leaves only; prefix `APP__gears____config__…`): # APP__gears__analytics__config__database_url # APP__gears__analytics__config__clickhouse_url # APP__gears__analytics__config__clickhouse_database @@ -18,6 +22,10 @@ # APP__gears__analytics__config__identity_url # APP__gears__analytics__config__redis_url # APP__gears__analytics__config__metric_catalog__tenant_default_id +# The oidc-authn-plugin's verification config is nested/list-shaped +# (jwt.trusted_issuers, expected_audience, http_client.custom_ca_certificate_paths) +# and so is supplied by the deployment's config layer (compose bind-mount / Helm +# configmap render), NOT by APP__ env vars. server: home_dir: "~/.insight" @@ -37,8 +45,10 @@ gears: openapi: title: "Insight Analytics API" version: "0.1.0" - description: "Read-only query service over predefined ClickHouse metrics — auth disabled" - auth_disabled: true + description: "Read-only query service over predefined ClickHouse metrics" + # Auth ENABLED: the oidc-authn-plugin verifies the ES256 gateway JWT and + # maps its claims into the SecurityContext (R1). No auth_disabled path. + auth_disabled: false gear-orchestrator: config: {} @@ -51,6 +61,51 @@ gears: config: vendor: "hyperspot" + # Downstream gateway-JWT verification (NGINX_BFF R1). Verifies the ES256 + # gateway JWT against the authenticator's JWKS (resolved via OIDC discovery on + # the issuer) and maps its claims into the SecurityContext. The deployment + # supplies the real issuer + self-signed CA (dev/e2e) via the config layer; + # the placeholders below (`.invalid`, never resolvable) fail closed. + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + # ES256 only — the gateway JWT is ECDSA P-256 (smaller signature, fast + # verify). `none` is always rejected by the plugin. + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + # Ordered allowlist; `issuer` = the token `iss` (authenticator + # gateway_issuer). `discovery_url` omitted → `{issuer}` is used, i.e. + # `{issuer}/.well-known/openid-configuration`. Overridden per deploy. + trusted_issuers: + - issuer: "https://gateway.invalid" + # Signed claims → SecurityContext. `tenant_id` is REQUIRED (a token + # without it is rejected — "no tenant, no auth", no Nil fallback). + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + # Dev/e2e self-signed CA (for the authenticator's TLS discovery front) + # is injected per deploy: + # custom_ca_certificate_paths: ["/certs/ca.pem"] + # Client-credentials exchange is UNUSED by analytics (no outbound S2S + # calls) but the block is required config. `discovery_url` must resolve to + # a trusted issuer; it is never fetched unless an exchange is performed. + s2s_oauth: + discovery_url: "https://gateway.invalid" + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + authz-resolver: config: vendor: "hyperspot" @@ -65,7 +120,8 @@ gears: vendor: "hyperspot" # Zero-config: derives the tenant from the SecurityContext at request time. - # With api-gateway.auth_disabled, toolkit-security injects DEFAULT_TENANT_ID. + # The oidc-authn-plugin sets that context's tenant from the gateway JWT's + # single signed `tenant_id` claim (mapped to `subject_tenant_id`). single-tenant-tr-plugin: config: vendor: "hyperspot" diff --git a/src/backend/services/analytics/helm/templates/configmap.yaml b/src/backend/services/analytics/helm/templates/configmap.yaml index 260e9c97f..20b583a01 100644 --- a/src/backend/services/analytics/helm/templates/configmap.yaml +++ b/src/backend/services/analytics/helm/templates/configmap.yaml @@ -11,11 +11,12 @@ data: # `insight-analytics-config` Secret (see deployment.yaml `envFrom`), # which override this YAML at load time. # - # The `api-gateway` system gear is the REST host with auth DISABLED: the - # platform api-gateway is the sole authenticator and proxies `/analytics` - # here, so this host injects a single-tenant SecurityContext - # (DEFAULT_TENANT_ID) and the analytics gear overrides the tenant from - # X-Insight-Tenant-Id when present. NO oidc plugin is registered. + # Auth ENABLED: the upstream `cf-gears-oidc-authn-plugin` verifies the ES256 + # gateway JWT (signature / iss / aud / exp) against the authenticator's JWKS — + # resolved via OIDC discovery on the issuer — and maps its claims straight into + # the SecurityContext (NGINX_BFF R1). The issuer / discovery / audience are + # rendered below from `.Values.gateway.*`; there is no `authverify` wrapper and + # no `X-Tenant-ID` selector. No auth_disabled path. analytics.yaml: | server: home_dir: "/app/data" @@ -30,7 +31,9 @@ data: bind_addr: "0.0.0.0:{{ .Values.service.port }}" # analytics is addressed directly (the platform gateway proxies # `/analytics` → here), so no prefix_path. - auth_disabled: true + # Auth ENABLED: the oidc-authn-plugin verifies the ES256 gateway JWT + # and maps its claims into the SecurityContext (R1). + auth_disabled: false cors_enabled: false enable_docs: false openapi: @@ -49,6 +52,51 @@ data: config: vendor: "hyperspot" + # Downstream gateway-JWT verification (NGINX_BFF R1). Verifies the ES256 + # gateway JWT against the authenticator's JWKS (resolved via OIDC discovery + # on the issuer) and maps its claims into the SecurityContext. Rendered + # from `.Values.gateway.*`; the plugin resolves the JWKS over https only. + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - {{ .Values.gateway.audience | default "internal-services" | quote }} + # `issuer` = the token `iss` (authenticator gateway_issuer); + # `discovery_url` defaults to `{issuer}` when unset. + trusted_issuers: + - issuer: {{ tpl (.Values.gateway.issuer | default "") . | quote }} + {{- with .Values.gateway.discoveryUrl }} + discovery_url: {{ tpl . $ | quote }} + {{- end }} + # tenant_id is REQUIRED (mapped to subject_tenant_id) — a token + # without it is rejected ("no tenant, no auth"). + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + {{- with .Values.gateway.caCertPaths }} + # Extra trust roots for the discovery/JWKS TLS (e.g. an internal CA). + custom_ca_certificate_paths: + {{- toYaml . | nindent 14 }} + {{- end }} + # Client-credentials exchange is unused by analytics but required + # config; discovery_url must name a trusted issuer, never fetched. + s2s_oauth: + discovery_url: {{ tpl (.Values.gateway.issuer | default "") . | quote }} + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + authz-resolver: config: vendor: "hyperspot" @@ -62,8 +110,8 @@ data: config: vendor: "hyperspot" - # Derives the tenant from the SecurityContext at request time; with - # api-gateway.auth_disabled, toolkit-security injects DEFAULT_TENANT_ID. + # Derives the tenant from the SecurityContext at request time; the + # oidc-authn-plugin sets it from the gateway JWT's single `tenant_id`. single-tenant-tr-plugin: config: vendor: "hyperspot" diff --git a/src/backend/services/analytics/helm/templates/deployment.yaml b/src/backend/services/analytics/helm/templates/deployment.yaml index 4df2e06be..79fa8cd71 100644 --- a/src/backend/services/analytics/helm/templates/deployment.yaml +++ b/src/backend/services/analytics/helm/templates/deployment.yaml @@ -48,10 +48,20 @@ spec: mountPath: /app/config/analytics.yaml subPath: analytics.yaml readOnly: true + {{- if .Values.gateway.caCertSecret }} + # Discovery-issuer CA for the oidc-authn-plugin's https JWKS fetch. + - name: authn-ca + mountPath: /etc/insight/authn-ca + readOnly: true + {{- end }} ports: - name: http containerPort: {{ .Values.service.port }} protocol: TCP + # Gateway-JWT verification (NGINX_BFF R1): the plugin's verification + # config is nested/list-shaped and rendered into the ConfigMap from + # `.Values.gateway.*` (issuer / discoveryUrl / audience / caCertPaths), + # so there are no APP__ env overrides for it here. envFrom: # Operator MUST pre-create this Secret (umbrella does it # automatically as `insight-analytics-config`). Provides the @@ -90,3 +100,11 @@ spec: - name: analytics-config configMap: name: {{ include "insight-analytics.fullname" . }}-gears-config + {{- if .Values.gateway.caCertSecret }} + - name: authn-ca + secret: + secretName: {{ .Values.gateway.caCertSecret | quote }} + items: + - key: ca.crt + path: ca.crt + {{- end }} diff --git a/src/backend/services/analytics/helm/values.yaml b/src/backend/services/analytics/helm/values.yaml index 3a64b15dd..3dc44fee1 100644 --- a/src/backend/services/analytics/helm/values.yaml +++ b/src/backend/services/analytics/helm/values.yaml @@ -4,7 +4,7 @@ podAnnotations: {} image: repository: ghcr.io/constructorfabric/insight-analytics - tag: "" # MUST be set by the operator. No `:latest` default. + tag: "" # MUST be set by the operator. No `:latest` default. pullPolicy: IfNotPresent service: @@ -29,19 +29,40 @@ resources: # # `..._metric_catalog__tenant_default_id` (UUID, optional): single-tenant # fallback for the Metric Catalog per DESIGN §2.2 -# `cpt-metric-cat-constraint-tenant-default`. Under the auth-disabled host the -# platform api-gateway is the sole authenticator and this host injects a -# single-tenant SecurityContext (toolkit-security's DEFAULT_TENANT_ID); the -# analytics gear overrides the tenant from the `X-Insight-Tenant-Id` -# header when present. The session-bound tenant ALWAYS wins over the -# configured default (security invariant; see -# `domain::auth::TenantAuthorization`). +# `cpt-metric-cat-constraint-tenant-default`. The gateway JWT is the tenant +# authority (verified here — NGINX_BFF R1); the oidc-authn-plugin sets the +# request tenant from the token's single signed `tenant_id` claim. The +# session-bound tenant ALWAYS wins over the configured default (security +# invariant; see `domain::auth::TenantAuthorization`). # # The umbrella populates `insight-analytics-config` automatically; for # standalone installs the operator pre-creates an equivalent Secret and # sets `existingSecret` to its name. existingSecret: "" +# Gateway-JWT verification (cf-gears-oidc-authn-plugin, NGINX_BFF R1). These +# render into the plugin's `jwt.trusted_issuers` / `expected_audience` in the +# ConfigMap (nested/list-shaped, so not env-injectable). The plugin resolves the +# JWKS via OIDC discovery on the issuer over https ONLY. +gateway: + # `iss` the token carries = the authenticator's gateway_issuer (gateway + # origin). The plugin fetches `{issuer}/.well-known/openid-configuration`. + issuer: "" + # Optional discovery override: where to fetch the discovery document, when the + # public `issuer` is not reachable from in-cluster. `{issuer}` is substituted + # with the matched `iss`. Leave empty to use the issuer directly. + discoveryUrl: "" + # Expected `aud`. Fixed by the authenticator's claim contract. + audience: "internal-services" + # Optional extra TLS trust roots (PEM paths inside the pod) for the discovery/ + # JWKS fetch — e.g. an internal CA. Empty for a publicly-trusted issuer cert. + caCertPaths: [] + # Secret holding the discovery issuer's CA under key `ca.crt` (the + # authenticator's authn-tls cert Secret). When set, it is mounted read-only at + # /etc/insight/authn-ca and `caCertPaths` should include + # `/etc/insight/authn-ca/ca.crt`. The umbrella wires both automatically. + caCertSecret: "" + # Health probes livenessProbe: httpGet: diff --git a/src/backend/services/analytics/src/api/handlers.rs b/src/backend/services/analytics/src/api/handlers.rs index 65262e275..3f177bbe1 100644 --- a/src/backend/services/analytics/src/api/handlers.rs +++ b/src/backend/services/analytics/src/api/handlers.rs @@ -31,6 +31,7 @@ use toolkit_security::SecurityContext; pub async fn get_person( Extension(state): Extension>, + headers: axum::http::HeaderMap, Path(email): Path, ) -> Result { if !state.identity.is_configured() { @@ -39,10 +40,20 @@ pub async fn get_person( ); } - let person = state.identity.get_person(&email).await.map_err(|e| { - tracing::error!(error = %e, email = %email, "identity resolution request failed"); - CanonicalError::internal("identity resolution unavailable").create() - })?; + // Forward the caller's gateway JWT to identity (G1): this is a user-context + // fan-out, so it propagates the incoming Authorization header verbatim. + let authorization = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + + let person = state + .identity + .get_person(&email, authorization) + .await + .map_err(|e| { + tracing::error!(error = %e, email = %email, "identity resolution request failed"); + CanonicalError::internal("identity resolution unavailable").create() + })?; match person { Some(p) => Ok(Json(serde_json::to_value(p).map_err(|e| { diff --git a/src/backend/services/analytics/src/api/http_live_tests.rs b/src/backend/services/analytics/src/api/http_live_tests.rs index 3d1488371..67d4ef7fc 100644 --- a/src/backend/services/analytics/src/api/http_live_tests.rs +++ b/src/backend/services/analytics/src/api/http_live_tests.rs @@ -38,7 +38,7 @@ use uuid::Uuid; use toolkit::api::OpenApiRegistryImpl; use toolkit_security::SecurityContext; -use crate::api::{AppState, register_routes}; +use crate::api::AppState; use crate::config::GearConfig; use crate::domain::admin_threshold::AdminThresholdService; use crate::domain::auth::{ConfigTenantAuthorization, TenantAuthorization}; @@ -103,24 +103,33 @@ fn build_state(db: DatabaseConnection) -> AppState { } } -/// Mount the real route table, then wrap it with a host-context injector so -/// `Extension` is present (the api-gateway host supplies this -/// in production). The injector is the OUTER layer, so it seeds the context -/// before `tenant_middleware` (inside `register_routes`) reads it. +/// Fixed test subject id. Handlers filter by tenant, not subject (subject only +/// surfaces in audit `actor_subject`). +const TEST_PERSON: Uuid = Uuid::from_u128(0x018f_0000_0000_7000_8000_0000_0000_0001); + +/// Mount the real operation table with the `SecurityContext` injected directly +/// for `tenant`, **bypassing** the host authn pipeline — the +/// `cf-gears-oidc-authn-plugin` verification needs a live JWKS, and that path is +/// covered by the plugin's own tests + the compose e2e. This suite is about the +/// handler -> DB glue for a known caller. fn app(db: DatabaseConnection, tenant: Uuid) -> Router { let openapi = OpenApiRegistryImpl::new(); let state = Arc::new(build_state(db)); - register_routes(Router::new(), &openapi, state) + let api = super::build_operations(Router::new(), &openapi) .layer(from_fn_with_state(tenant, inject_host_context)) + .layer(axum::Extension(state)); + Router::new().merge(api) } +/// Seed a `SecurityContext` (subject + tenant) the way `authverify` would. async fn inject_host_context( axum::extract::State(tenant): axum::extract::State, mut req: Request, next: axum::middleware::Next, ) -> Response { let Ok(ctx) = SecurityContext::builder() - .subject_id(Uuid::nil()) + .subject_id(TEST_PERSON) + .subject_type("user") .subject_tenant_id(tenant) .build() else { @@ -182,6 +191,30 @@ async fn list_metrics_returns_200_items() -> TestResult { Ok(()) } +#[tokio::test] +#[ignore = "requires live MariaDB (INTEGRATION_TESTS_MARIADB_URL)"] +async fn get_person_forwards_authorization_then_5xx_on_dead_identity() -> TestResult { + // Identity is a dead address (127.0.0.1:1), so this exercises the G1 + // Authorization-forwarding path — the handler reads the incoming bearer and + // the IdentityClient attaches it to the outbound call — and the mapping of + // the (unreachable) failure to 5xx, with no live identity provider needed. + let Some(db) = connect_or_skip().await else { + return Ok(()); + }; + let app = app(db, Uuid::now_v7()); + let req = Request::builder() + .uri("/v1/persons/nobody@example.com") + .header("authorization", "Bearer test-gateway-jwt") + .body(Body::empty())?; + let resp = app.oneshot(req).await?; + assert!( + resp.status().is_server_error(), + "dead identity should map to 5xx, got {}", + resp.status() + ); + Ok(()) +} + #[tokio::test] #[ignore = "requires live MariaDB (INTEGRATION_TESTS_MARIADB_URL)"] async fn get_metric_by_id_returns_200() -> TestResult { diff --git a/src/backend/services/analytics/src/api/mod.rs b/src/backend/services/analytics/src/api/mod.rs index ea5492610..212faa682 100644 --- a/src/backend/services/analytics/src/api/mod.rs +++ b/src/backend/services/analytics/src/api/mod.rs @@ -7,9 +7,6 @@ pub(crate) mod error; mod handlers; mod metric_results; -#[cfg(test)] -mod tenant_resolution_tests; - #[cfg(test)] mod http_live_tests; @@ -17,13 +14,11 @@ mod http_live_tests; mod openapi_tests; use axum::http::StatusCode; -use axum::middleware::from_fn; use axum::{Extension, Router}; use sea_orm::DatabaseConnection; use std::sync::Arc; use toolkit::api::{OpenApiInfo, OpenApiRegistry, OpenApiRegistryImpl, OperationBuilder}; -use crate::auth; use crate::config::GearConfig; use crate::domain::admin_threshold::AdminThresholdService; use crate::domain::admin_threshold::dto as admin_dto; @@ -75,16 +70,20 @@ pub struct AppState { /// extension scope to the analytics gear's routes only — not the host's `/health`, /// `/healthz`, `/openapi.json`, `/docs` — then merges it into the host router. /// -/// The shared `Arc` is attached via `router.layer(Extension(state))` -/// and the per-request tenant override via `router.layer(from_fn(tenant_middleware))`. +/// The shared `Arc` is attached via `router.layer(Extension(state))`. +/// Gateway-JWT identity is enforced entirely by the host authn pipeline: the +/// `oidc-authn-plugin` verifies the ES256 gateway JWT (signature / `iss` / +/// `aud` / `exp`) against the authenticator's JWKS and maps its claims to the +/// request `SecurityContext` via configured `claim_mapping` (`sub` → +/// `subject_id`, `tenant_id` → `subject_tenant_id`, `roles` → `token_scopes`; +/// `subject_tenant_id` is required). No bespoke mapping layer here +/// (`NGINX_BFF` R1 / G2). pub fn register_routes( host_router: Router, openapi: &dyn OpenApiRegistry, state: Arc, ) -> Router { - let api = build_operations(Router::new(), openapi) - .layer(from_fn(auth::tenant_middleware)) - .layer(Extension(state)); + let api = build_operations(Router::new(), openapi).layer(Extension(state)); host_router.merge(api) } diff --git a/src/backend/services/analytics/src/api/tenant_resolution_tests.rs b/src/backend/services/analytics/src/api/tenant_resolution_tests.rs deleted file mode 100644 index 63a65120a..000000000 --- a/src/backend/services/analytics/src/api/tenant_resolution_tests.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Integration tests for the tenant-override middleware. -//! -//! Drive a minimal router that mounts `auth::tenant_middleware` (now a -//! stateless `from_fn` layer) in front of a test-only `/_tenant_echo` route and -//! assert on the response. The host (api-gateway, `auth_disabled = true`) -//! injects a `toolkit_security::SecurityContext` into the request extensions; -//! these tests seed that context directly via a preceding layer, then verify: -//! -//! - no `X-Insight-Tenant-Id` header → the injected tenant is preserved, -//! - `X-Insight-Tenant-Id: ` → the tenant is overridden by the header, -//! - a nil-Uuid header is ignored (does not override). -//! -//! No MariaDB / ClickHouse / Identity client is required — the middleware is -//! pure (header parse + context rebuild). - -use axum::body::{Body, to_bytes}; -use axum::http::{Request, StatusCode}; -use axum::middleware::{from_fn, from_fn_with_state}; -use axum::routing::get; -use axum::{Extension, Json, Router}; -use serde_json::Value; -use toolkit_security::SecurityContext; -use tower::ServiceExt; -use uuid::Uuid; - -use crate::auth::{TENANT_HEADER, tenant_middleware}; - -type TestResult = Result<(), Box>; - -const INJECTED: Uuid = Uuid::from_u128(0x9999_9999_9999_9999_9999_9999_9999_9999_u128); -const OVERRIDE: Uuid = Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111_u128); - -/// Test-only echo handler; reflects the resolved tenant out of the -/// `SecurityContext` so the assertions below can verify it. -async fn tenant_echo(Extension(ctx): Extension) -> Json { - Json(serde_json::json!({ "tenant_id": ctx.subject_tenant_id() })) -} - -/// Build the router with a host-context-injecting layer (simulating the -/// api-gateway host) followed by the tenant-override middleware. -fn router_with_injected(tenant: Uuid) -> Router { - Router::new() - .route("/_tenant_echo", get(tenant_echo)) - // `tenant_middleware` runs first (it is the OUTER layer), then the - // injector. Axum runs layers bottom-to-top on the request path, so the - // injector (added last) executes first and seeds the host context, then - // `tenant_middleware` reads/overrides it. - .layer(from_fn(tenant_middleware)) - .layer(from_fn_with_state(tenant, inject_host_context)) -} - -/// Stand-in for the host's `SecurityContext` injection: builds a single-tenant -/// context bound to `tenant` and inserts it into the request extensions. -async fn inject_host_context( - axum::extract::State(tenant): axum::extract::State, - mut req: Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let Ok(ctx) = SecurityContext::builder() - .subject_id(Uuid::nil()) - .subject_tenant_id(tenant) - .build() - else { - unreachable!("subject_id + subject_tenant_id are set") - }; - req.extensions_mut().insert(ctx); - next.run(req).await -} - -fn req_get(uri: &str) -> Result, axum::http::Error> { - Request::builder() - .uri(uri) - .method("GET") - .body(Body::empty()) -} - -fn req_get_with_tenant(uri: &str, tenant: Uuid) -> Result, axum::http::Error> { - Request::builder() - .uri(uri) - .method("GET") - .header(TENANT_HEADER, tenant.to_string()) - .body(Body::empty()) -} - -async fn body_json(resp: axum::response::Response) -> Result> { - let bytes = to_bytes(resp.into_body(), 64 * 1024).await?; - Ok(serde_json::from_slice(&bytes)?) -} - -#[tokio::test] -async fn no_header_preserves_injected_tenant() -> TestResult { - // No override header → the host-injected tenant flows through unchanged. - let app = router_with_injected(INJECTED); - - let resp = app.oneshot(req_get("/_tenant_echo")?).await?; - - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await?; - assert_eq!(body["tenant_id"], INJECTED.to_string()); - Ok(()) -} - -#[tokio::test] -async fn header_overrides_injected_tenant() -> TestResult { - // `X-Insight-Tenant-Id` present → it overrides the host-injected tenant. - let app = router_with_injected(INJECTED); - - let resp = app - .oneshot(req_get_with_tenant("/_tenant_echo", OVERRIDE)?) - .await?; - - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await?; - assert_eq!( - body["tenant_id"], - OVERRIDE.to_string(), - "header tenant must override the injected tenant" - ); - Ok(()) -} - -#[tokio::test] -async fn nil_uuid_header_is_ignored() -> TestResult { - // Defense in depth: a parseable-but-non-identity tenant value - // (`Uuid::nil()`) must NOT override the injected tenant. - let app = router_with_injected(INJECTED); - - let resp = app - .oneshot(req_get_with_tenant("/_tenant_echo", Uuid::nil())?) - .await?; - - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await?; - assert_eq!(body["tenant_id"], INJECTED.to_string()); - Ok(()) -} - -#[tokio::test] -async fn multi_valued_header_is_ignored() -> TestResult { - // A hostile/misbehaving upstream sending two `X-Insight-Tenant-Id` values - // must not silently bind to the first — the override is refused and the - // injected tenant is preserved. - let app = router_with_injected(INJECTED); - - let req = Request::builder() - .uri("/_tenant_echo") - .method("GET") - .header(TENANT_HEADER, OVERRIDE.to_string()) - .header( - TENANT_HEADER, - Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222_u128).to_string(), - ) - .body(Body::empty())?; - let resp = app.oneshot(req).await?; - - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await?; - assert_eq!(body["tenant_id"], INJECTED.to_string()); - Ok(()) -} diff --git a/src/backend/services/analytics/src/auth.rs b/src/backend/services/analytics/src/auth.rs deleted file mode 100644 index c88f1a87d..000000000 --- a/src/backend/services/analytics/src/auth.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Tenant-override middleware. -//! -//! Under the auth-disabled `api-gateway` host the platform injects a -//! single-tenant `toolkit_security::SecurityContext` (`DEFAULT_TENANT_ID`) into -//! every request's extensions. This thin, stateless middleware reads that -//! context and, when an `X-Insight-Tenant-Id` header is present and parses to -//! a non-nil Uuid, rebuilds the context with that tenant so per-request tenant -//! overrides (used by internal hops / dev tooling) take effect. Handlers and -//! domain services consume `toolkit_security::SecurityContext` exclusively. -//! -//! Mirrors identity's `HeaderTenantContext` so api-gateway / dbt-runner / etc. -//! send the same header to both services. - -use axum::extract::Request; -use axum::middleware::Next; -use axum::response::Response; -use toolkit_security::SecurityContext; -use uuid::Uuid; - -/// Header that carries the session-bound tenant on internal hops. Matches -/// `HeaderTenantContext.HeaderName` on the identity service. -pub const TENANT_HEADER: &str = "X-Insight-Tenant-Id"; - -/// Access scope resolved from the authorization layer. -/// -/// Defines which org units and time ranges the user can see. -/// In production, populated by the authz plugin. -/// Currently stubbed to return full access. -#[derive(Debug, Clone)] -pub struct AccessScope { - /// Org unit IDs the user is allowed to see. - #[allow(dead_code)] // will be consumed by query engine for row-level filtering - pub visible_org_unit_ids: Vec, - // TODO: add effective_from/effective_to per org unit for time-scoped visibility -} - -/// Stateless middleware that takes the host-injected `SecurityContext` and, -/// when `X-Insight-Tenant-Id` carries a non-nil Uuid, overrides the tenant by -/// rebuilding the context. Always re-inserts a `SecurityContext` plus the -/// `AccessScope` into the request extensions. -/// -/// The host (api-gateway, `auth_disabled = true`) always provides a tenant -/// (`DEFAULT_TENANT_ID`), so there is no unresolved-tenant rejection path here. -pub async fn tenant_middleware(mut req: Request, next: Next) -> Response { - // Host-injected context (fallback to anonymous if, for some reason, the - // host didn't inject one — e.g. a direct pod hit on a non-public route). - let base = req - .extensions() - .get::() - .cloned() - .unwrap_or_else(SecurityContext::anonymous); - - let ctx = match read_session_tenant(&req) { - Some(tenant_id) => rebuild_with_tenant(&base, tenant_id), - None => base, - }; - - let scope = resolve_access_scope(&ctx); - - req.extensions_mut().insert(ctx); - req.extensions_mut().insert(scope); - - next.run(req).await -} - -/// Rebuild a `SecurityContext` carrying over subject id/type/scopes but with -/// the overridden `subject_tenant_id`. Falls back to the base context if the -/// builder rejects the inputs (it requires `subject_id` + `subject_tenant_id`, -/// both of which we supply). -fn rebuild_with_tenant(base: &SecurityContext, tenant_id: Uuid) -> SecurityContext { - let mut builder = SecurityContext::builder() - .subject_id(base.subject_id()) - .subject_tenant_id(tenant_id) - .token_scopes(base.token_scopes().to_vec()); - if let Some(subject_type) = base.subject_type() { - builder = builder.subject_type(subject_type); - } - builder.build().unwrap_or_else(|_| base.clone()) -} - -/// Parses the session-bound tenant from `X-Insight-Tenant-Id`. Rejects: -/// - multi-valued headers (a hostile or misbehaving upstream sending two -/// `X-Insight-Tenant-Id` lines would otherwise silently bind to the first), -/// - `Uuid::nil()` (parseable but non-identity value must not pin tenant context), -/// - any unparseable value. -/// -/// Mirrors identity's `HeaderTenantContext.Resolve`. -fn read_session_tenant(req: &Request) -> Option { - let mut iter = req.headers().get_all(TENANT_HEADER).iter(); - let first = iter.next()?; - if iter.next().is_some() { - // More than one value — refuse to pick a winner. - return None; - } - let raw = first.to_str().ok()?; - Uuid::parse_str(raw.trim()).ok().filter(|id| !id.is_nil()) -} - -/// Resolve access scope for the given security context. -/// -/// # Stub implementation -/// -/// Returns unrestricted access. In production, this would: -/// 1. Call authz resolver with `subject_id` -/// 2. Get visible `org_unit_ids` + `effective_from`/`to` per unit -/// 3. Return access scope -fn resolve_access_scope(_ctx: &SecurityContext) -> AccessScope { - AccessScope { - visible_org_unit_ids: vec![], // empty = no org filtering (dev mode) - } -} diff --git a/src/backend/services/analytics/src/domain/auth.rs b/src/backend/services/analytics/src/domain/auth.rs index 86345fada..e3914d6a9 100644 --- a/src/backend/services/analytics/src/domain/auth.rs +++ b/src/backend/services/analytics/src/domain/auth.rs @@ -58,14 +58,14 @@ pub type ActorSubject = String; /// `None` as a 400 `invalid_argument` per /// `cpt-metric-cat-constraint-tenant-default`. pub trait TenantAuthorization: Send + Sync { - /// `session_tenant`: the tenant attached to the session by upstream auth - /// (today: the `X-Insight-Tenant-Id` header stub; eventually the JWT - /// `insight_tenant_id` claim). `None` when the session carries no tenant. + /// `session_tenant`: the request's resolved tenant. It comes from the signed + /// gateway JWT — the `authverify` layer selects it from the JWT's `tenants[]` + /// via the `X-Tenant-ID` selector (G2) and sets `SecurityContext`'s + /// `subject_tenant_id`. `None` when the caller carries no tenant. /// - /// No longer called from the request path under the auth-disabled host - /// (the gateway injects the tenant and `crate::auth::tenant_middleware` - /// overrides it directly), but retained on the trait + covered by unit - /// tests for the single-tenant-fallback security invariant. + /// Not called from the request path today (`authverify` sets the context's + /// tenant directly), but retained on the trait + covered by unit tests for + /// the single-tenant-fallback security invariant. #[allow(dead_code)] fn resolve_tenant(&self, session_tenant: Option) -> Option; diff --git a/src/backend/services/analytics/src/infra/identity/mod.rs b/src/backend/services/analytics/src/infra/identity/mod.rs index 287e20e8a..17d2da7a7 100644 --- a/src/backend/services/analytics/src/infra/identity/mod.rs +++ b/src/backend/services/analytics/src/infra/identity/mod.rs @@ -29,10 +29,93 @@ pub struct Subordinate { pub job_title: String, } -// `Person` is the `GET /v1/persons/{email}` response body. `Subordinate` is -// nested inside it and needs only `ToSchema` (above). +// `Person` is the analytics-facing person model (the `GET /v1/persons/{email}` +// response body of *this* service). `Subordinate` is nested inside it and needs +// only `ToSchema` (above). impl toolkit::api::api_dto::ResponseApiDto for Person {} +/// Request body of the identity service's `POST /v1/profiles` +/// (`ResolveProfileCommandModel`). For an email lookup, `value_type="email"`, +/// `value` is the email, and the two `insight_source_*` fields are omitted. +#[derive(Debug, Serialize)] +struct ResolveProfileRequest<'a> { + value_type: &'a str, + value: &'a str, +} + +/// Response body of the identity service's `POST /v1/profiles` +/// (`ProfileResponse`). Only the fields analytics maps into its own [`Person`] +/// model are declared; identity omits null-valued optionals from the JSON, so +/// every non-required field is `Option` and defaults on absence. +#[derive(Debug, Deserialize)] +struct ProfileResponse { + #[serde(default)] + email: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + first_name: Option, + #[serde(default)] + last_name: Option, + #[serde(default)] + department: Option, + #[serde(default)] + division: Option, + #[serde(default)] + job_title: Option, + #[serde(default)] + status: Option, + #[serde(default)] + supervisor_email: Option, + #[serde(default)] + supervisor_name: Option, + #[serde(default)] + subordinates: Vec, +} + +/// A subordinate entry inside `ProfileResponse.subordinates` (identity's +/// `PersonResponse`). Only the three fields analytics' [`Subordinate`] carries +/// are declared; the rest of `PersonResponse` is ignored. +#[derive(Debug, Deserialize)] +struct ProfileSubordinate { + #[serde(default)] + email: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + job_title: Option, +} + +impl From for Person { + fn from(p: ProfileResponse) -> Self { + Person { + // `GET /v1/persons` returned these as required strings; `ProfileResponse` + // makes them optional (omitted when null). Preserve the analytics `Person` + // contract by defaulting a missing value to the empty string. + email: p.email.unwrap_or_default(), + display_name: p.display_name.unwrap_or_default(), + first_name: p.first_name.unwrap_or_default(), + last_name: p.last_name.unwrap_or_default(), + department: p.department.unwrap_or_default(), + division: p.division.unwrap_or_default(), + job_title: p.job_title.unwrap_or_default(), + status: p.status.unwrap_or_default(), + // These were already optional on `Person`; forward verbatim. + supervisor_email: p.supervisor_email, + supervisor_name: p.supervisor_name, + subordinates: p + .subordinates + .into_iter() + .map(|s| Subordinate { + email: s.email.unwrap_or_default(), + display_name: s.display_name.unwrap_or_default(), + job_title: s.job_title.unwrap_or_default(), + }) + .collect(), + } + } +} + /// Identity API client. #[derive(Clone)] pub struct IdentityClient { @@ -53,16 +136,43 @@ impl IdentityClient { /// Look up a person by email address. /// - /// Calls `GET {base_url}/v1/persons/{email}`. - /// Returns `None` if the person is not found (404). + /// Calls `POST {base_url}/v1/profiles` with `{ value_type: "email", value: + /// }` — the contemporary, non-deprecated resolution endpoint. (The + /// old `GET /v1/persons/{email}` emits RFC 8594 `Deprecation` headers; the + /// email in the path leaks into observability surfaces, so it must not be + /// used.) The `ProfileResponse` is mapped into this service's [`Person`] + /// model, keeping the analytics `/v1/persons/{email}` response unchanged. + /// + /// Returns `None` if the person is not found (404) — identity resolves to + /// exactly one person, so a 422 `ambiguous_profile` (multiple matches) is + /// surfaced as an error rather than silently collapsed. + /// + /// `authorization` is the caller's incoming `Authorization` header (the + /// gateway JWT). Identity now verifies that JWT itself (`NGINX_BFF` R1), so a + /// user-context call **must** forward it (G1) — otherwise identity replies + /// 401. It is threaded through verbatim rather than re-minted: the reissue- + /// ahead margin guarantees the token still verifies across this extra hop. /// /// # Errors /// /// Returns error if the service is unreachable or returns an unexpected error. - pub async fn get_person(&self, email: &str) -> anyhow::Result> { - let url = format!("{}/v1/persons/{email}", self.base_url); + pub async fn get_person( + &self, + email: &str, + authorization: Option<&str>, + ) -> anyhow::Result> { + let url = format!("{}/v1/profiles", self.base_url); + + let body = ResolveProfileRequest { + value_type: "email", + value: email, + }; - let resp = self.http.get(&url).send().await?; + let mut req = self.http.post(&url).json(&body); + if let Some(auth) = authorization { + req = req.header(reqwest::header::AUTHORIZATION, auth); + } + let resp = req.send().await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); @@ -75,8 +185,8 @@ impl IdentityClient { anyhow::bail!("identity service returned {status}"); } - let person: Person = resp.json().await?; - Ok(Some(person)) + let profile: ProfileResponse = resp.json().await?; + Ok(Some(profile.into())) } /// Check if the identity service is configured (URL is non-empty). diff --git a/src/backend/services/analytics/src/main.rs b/src/backend/services/analytics/src/main.rs index f9a41bc41..f4d0f5a79 100644 --- a/src/backend/services/analytics/src/main.rs +++ b/src/backend/services/analytics/src/main.rs @@ -7,10 +7,12 @@ //! //! Runs as a gears-rust host on [`toolkit::bootstrap::run_server`]. The //! `api-gateway` system gear is the REST host; the analytics functionality is -//! the [`gear::AnalyticsApiGear`] (`rest` + `stateful`). Auth is **disabled** on -//! this host — the platform api-gateway is the sole authenticator and proxies -//! to us — so the host injects a single-tenant `SecurityContext` and a thin -//! layer overrides the tenant from `X-Insight-Tenant-Id` (see [`crate::auth`]). +//! the [`gear::AnalyticsApiGear`] (`rest` + `stateful`). Auth is **enabled** on +//! this host: `oidc-authn-plugin` verifies the ES256 gateway JWT (signature / +//! `iss` / `aud` / `exp`) against the authenticator's JWKS, and the `authverify` +//! layer maps the signed claims (`sub`, `roles`, `tenants[]` + the `X-Tenant-ID` +//! selector) into the request `SecurityContext` (`NGINX_BFF` R1 / G2). There is +//! no `auth_disabled` path — a request without a valid gateway JWT gets 401. //! //! The MariaDB connection, its 45 sea-orm migrations, and the startup CHECK / //! product-default probes remain self-managed inside the gear (we do not use @@ -26,21 +28,22 @@ //! ``` mod api; -mod auth; mod config; mod domain; mod gear; mod infra; mod migration; -// System gears — linked via inventory for the REST host and the (disabled) -// auth pipeline. Mirrors the api-gateway service's no-auth gear set, minus the -// OIDC plugin / proxy / auth-info (this host never authenticates). +// System gears — linked via inventory for the REST host + auth pipeline. +// `oidc-authn-plugin` verifies the ES256 gateway JWT against the authenticator's +// JWKS (host config points its issuer/JWKS at the authenticator); `authverify` +// then maps the signed claims (see `api::register_routes`). use api_gateway as _; use authn_resolver as _; use authz_resolver as _; use gear_orchestrator as _; use grpc_hub as _; +use oidc_authn_plugin as _; use single_tenant_tr_plugin as _; use static_authz_plugin as _; use tenant_resolver as _; diff --git a/src/backend/services/api-gateway/Cargo.toml b/src/backend/services/api-gateway/Cargo.toml deleted file mode 100644 index 8f5f3a25f..000000000 --- a/src/backend/services/api-gateway/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "insight-api-gateway" -version = "0.1.0" -description = "Insight API Gateway — entry point for all backend services" -edition.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -rust-version.workspace = true - -[[bin]] -name = "insight-api-gateway" -path = "src/main.rs" - -[lints] -workspace = true - -[dependencies] -# gears-rust -toolkit = { workspace = true, features = ["bootstrap"] } - -# System gears (linked via inventory for runtime discovery) -api_gateway = { workspace = true } -authn-resolver = { workspace = true } -authz-resolver = { workspace = true } -tenant-resolver = { workspace = true } -grpc_hub = { workspace = true } -gear_orchestrator = { workspace = true } -types_registry = { workspace = true } - -# Plugins -static-authz-plugin = { workspace = true } -single-tenant-tr-plugin = { workspace = true } -oidc-authn-plugin = { path = "../../plugins/oidc-authn-plugin" } - -# Auth info + proxy gears -toolkit-canonical-errors = { workspace = true } -async-trait = { workspace = true } -axum = "0.8" -reqwest = { workspace = true } -serde = { workspace = true } - -# Runtime -tokio = { workspace = true } -tracing = { workspace = true } -anyhow = { workspace = true } -clap = { workspace = true } - -[dev-dependencies] -tower = { version = "0.5", features = ["util"] } diff --git a/src/backend/services/api-gateway/Dockerfile b/src/backend/services/api-gateway/Dockerfile deleted file mode 100644 index 777317528..000000000 --- a/src/backend/services/api-gateway/Dockerfile +++ /dev/null @@ -1,113 +0,0 @@ -# Rebuild marker (2026-06-11, take 2): republish so the chart-pinned tag advances to -# an SLSA-attested image — current appVersion predates the provenance -# attestation steps in build-images.yml. -# Take 2: the first publish-chart attempt failed before committing the -# appVersion bumps back to main (chart attest step lacked registry login). -# Multi-stage build for Insight API Gateway -# -# Build context: src/backend/ -# Usage: cd src/backend && docker build -f services/api-gateway/Dockerfile -t insight-api-gateway:dev . - -# Stage 1: Builder -FROM rust:1.95-bookworm AS builder - -# Install protobuf-compiler (required by grpc-hub → prost-build) -RUN apt-get update && \ - apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /build - -# --- Dependency caching layer --- -# Copy Cargo manifests and create stubs so cargo can cache all dependencies. -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 plugins/oidc-authn-plugin/Cargo.toml ./plugins/oidc-authn-plugin/Cargo.toml -COPY services/api-gateway/Cargo.toml ./services/api-gateway/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 -# 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 - -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 plugins/oidc-authn-plugin/src && echo "" > plugins/oidc-authn-plugin/src/lib.rs && \ - mkdir -p services/api-gateway/src && echo "fn main() {}" > services/api-gateway/src/main.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 && \ - mkdir -p tools/routegen/src && echo "fn main() {}" > tools/routegen/src/main.rs - -# Cache dependencies -RUN cargo build --release --bin insight-api-gateway 2>/dev/null || true - -# --- Source layer --- -# Remove stubs and cached binaries, copy real source + config -RUN rm -rf libs/insight-clickhouse/src plugins/oidc-authn-plugin/src services/api-gateway/src \ - target/release/insight-api-gateway target/release/deps/insight_api_gateway* \ - target/release/deps/oidc_authn_plugin* target/release/deps/liboidc_authn_plugin* \ - target/release/deps/insight_clickhouse* target/release/deps/libinsight_clickhouse* - -COPY libs/insight-clickhouse/src/ ./libs/insight-clickhouse/src/ -COPY plugins/oidc-authn-plugin/src/ ./plugins/oidc-authn-plugin/src/ -COPY services/api-gateway/src/ ./services/api-gateway/src/ -COPY services/api-gateway/config/ ./services/api-gateway/config/ - -# Build release binary (deps cached, only our code compiles) -RUN cargo build --release --bin insight-api-gateway - -# Stage 2: Runtime -FROM debian:bookworm-slim - -ARG TARGETARCH -ARG WATCHEXEC_VERSION=2.3.2 - -# Runtime deps + watchexec. The watcher is used by the local -# docker-compose dev stack when ENABLE_AUTO_RELOAD=true to restart the -# binary on bind-mount change. Multi-arch via BuildKit's $TARGETARCH. -RUN set -eux; \ - case "${TARGETARCH:-amd64}" in \ - amd64) WX_ARCH=x86_64-unknown-linux-musl ;; \ - arm64) WX_ARCH=aarch64-unknown-linux-musl ;; \ - *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ - esac; \ - apt-get update; \ - apt-get install -y --no-install-recommends ca-certificates curl xz-utils; \ - curl -fsSL "https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/watchexec-${WATCHEXEC_VERSION}-${WX_ARCH}.tar.xz" \ - | tar -xJ -C /tmp; \ - install -m 0755 "/tmp/watchexec-${WATCHEXEC_VERSION}-${WX_ARCH}/watchexec" /usr/local/bin/watchexec; \ - rm -rf /tmp/watchexec-*; \ - apt-get purge -y curl xz-utils; \ - apt-get autoremove -y; \ - rm -rf /var/lib/apt/lists/* - -# Shared dev/prod entrypoint. ENABLE_AUTO_RELOAD switches behaviour at -# runtime; see docker-entrypoint.sh for the contract. -COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app - -COPY --from=builder /build/target/release/insight-api-gateway /app/insight-api-gateway -COPY --from=builder /build/services/api-gateway/config/ /app/config/ - -RUN mkdir -p /app/data && \ - useradd -U -u 1000 -m appuser && \ - chown -R 1000:1000 /app -USER 1000 - -EXPOSE 8080 - -ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] -# CMD format: -- [args...]. Compose may override -# `command:` for dev (e.g. swap insight.yaml for no-auth.yaml). -CMD ["/app/insight-api-gateway", "--", "/app/insight-api-gateway", "-c", "/app/config/insight.yaml", "run"] diff --git a/src/backend/services/api-gateway/README.md b/src/backend/services/api-gateway/README.md deleted file mode 100644 index 6af371cfc..000000000 --- a/src/backend/services/api-gateway/README.md +++ /dev/null @@ -1,182 +0,0 @@ -# Insight API Gateway - -HTTP entry point for all Insight backend services. Built on cyberfabric-core ModKit with OIDC/JWT authentication. - -## What it does - -- Routes HTTP requests to backend service gears -- Validates JWT bearer tokens against an OIDC provider (Okta, Keycloak, Auth0, etc.) -- Enforces RBAC and tenant isolation via authz-resolver -- Provides OpenAPI documentation, CORS, rate limiting, request tracing -- Returns RFC 9457 Problem Details for all errors - -## Quick start (local dev, no auth) - -```bash -cd src/backend -cargo run --bin insight-api-gateway -- run -c services/api-gateway/config/no-auth.yaml -``` - -Open `http://localhost:8080/api/v1/docs` for OpenAPI UI. - -## Quick start (with OIDC) - -```bash -export OIDC_ISSUER_URL=https://dev-12345.okta.com/oauth2/default -export OIDC_AUDIENCE=api://insight - -cd src/backend -cargo run --bin insight-api-gateway -- run -c services/api-gateway/config/insight.yaml -``` - -Test with a valid Okta token: - -```bash -curl -H "Authorization: Bearer " http://localhost:8080/api/v1/docs -``` - -## Configuration - -Configuration is loaded in layers (highest priority last): - -1. **Defaults** — hardcoded in code -2. **YAML file** — `-c config/insight.yaml` -3. **Environment variables** — `APP__gears____config__` -4. **CLI flags** — `--verbose`, `--print-config` - -### Key environment variables - -| Variable | Description | Example | -|----------|-------------|---------| -| `OIDC_ISSUER_URL` | OIDC issuer URL | `https://dev-12345.okta.com/oauth2/default` | -| `OIDC_AUDIENCE` | Expected JWT audience | `api://insight` | -| `APP__gears__api-gateway__config__bind_addr` | Listen address | `0.0.0.0:8080` | -| `APP__gears__api-gateway__config__auth_disabled` | Disable auth | `true` | - -### Okta setup - -1. Create an Okta application (Web or SPA) -2. Note the **Issuer URI** (e.g., `https://dev-12345.okta.com/oauth2/default`) -3. Note the **Audience** (e.g., `api://insight`) -4. Set `OIDC_ISSUER_URL` and `OIDC_AUDIENCE` environment variables -5. The plugin auto-discovers JWKS keys from `{issuer}/v1/keys` - -### Custom claims - -The plugin extracts these claims from the JWT: - -| Claim | Maps to | Default behavior | -|-------|---------|-----------------| -| `sub` | `subject_id` (as UUID v5 hash) | Required | -| `scp` or `scope` | `token_scopes` | Empty if neither present (authz layer decides) | -| `{tenant_claim}` (configurable) | `subject_tenant_id` | Warning logged if missing. Rejected if `require_tenant_claim: true`, nil UUID if `false` (default). | - -To use a different claim for tenant ID, set `tenant_claim` in config. Set `require_tenant_claim: true` to reject tokens without a valid tenant UUID. - -## Deploy to Kubernetes - -### Helm install - -```bash -helm install insight-gw ./helm \ - --set oidc.issuerUrl=https://dev-12345.okta.com/oauth2/default \ - --set oidc.audience=api://insight \ - --set ingress.host=insight.example.com -``` - -### Helm values - -See `helm/values.yaml` for all configurable values. Key ones: - -```yaml -oidc: - issuerUrl: "https://dev-12345.okta.com/oauth2/default" - audience: "api://insight" - -authDisabled: false # Set true for dev without IdP - -ingress: - enabled: true - host: insight.example.com - tls: - enabled: true - secretName: insight-tls -``` - -### Local dev without auth - -```bash -helm install insight-gw ./helm --set authDisabled=true -``` - -## Architecture - -```text -Client → Ingress → API Gateway → [Auth Middleware] → Service Gears - │ - ├── OIDC Plugin (JWT validation via JWKS) - ├── AuthZ Resolver (RBAC + org scoping) - └── Tenant Resolver (workspace isolation) -``` - -The gateway is a gears-rust Toolkit server binary that links: - -| Gear | Purpose | -|------|---------| -| `api-gateway` | Axum HTTP server, routing, OpenAPI, CORS, rate limiting | -| `oidc-authn-plugin` | JWT validation against OIDC provider | -| `authn-resolver` | Authentication gateway (delegates to OIDC plugin) | -| `authz-resolver` | Authorization (static plugin now, org-tree plugin later) | -| `tenant-resolver` | Multi-tenant workspace isolation | -| `grpc-hub` | Internal gRPC communication | -| `gear-orchestrator` | Gear lifecycle management | -| `types-registry` | GTS type/plugin discovery | - -## Public endpoints (no auth required) - -| Endpoint | Response | Purpose | -|----------|----------|---------| -| `GET /health` | `{"status":"healthy","timestamp":"..."}` | K8s liveness + readiness probe | -| `GET /healthz` | `ok` | Simple liveness check | -| `GET /auth/config` | OIDC configuration JSON (see below) | Frontend reads this to initiate OIDC login | - -### `GET /auth/config` - -Returns the OIDC provider details the frontend needs to start the Authorization Code flow with PKCE. No token required. - -```json -{ - "issuer_url": "https://dev-12345.okta.com/oauth2/default", - "client_id": "0oa1b2c3d4e5f6g7h8i9", - "redirect_uri": "http://localhost:3000/callback", - "scopes": ["openid", "profile", "email"], - "response_type": "code" -} -``` - -Frontend flow: -1. Fetch `/auth/config` on startup -2. Construct Okta authorize URL from `issuer_url` + `client_id` + `redirect_uri` + `scopes` -3. Redirect user to Okta login -4. Okta redirects back with auth code -5. Frontend exchanges code for tokens -6. Frontend sends `Authorization: Bearer ` on every API call -7. If API returns 401 → redirect to Okta again (token expired) - -Configured via `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI` env vars (or Helm values `oidc.clientId`, `oidc.redirectUri`). - -## Building - -```bash -cd src/backend -cargo build --release --bin insight-api-gateway -``` - -Docker (build context is `cf/` parent directory containing both repos): - -```bash -cd /path/to/cf -docker build -f insight/src/backend/services/api-gateway/Dockerfile -t insight-api-gateway:dev . -``` - -See `Dockerfile` for the full multi-stage build (protoc, non-root user, bookworm-slim runtime). diff --git a/src/backend/services/api-gateway/config/insight.yaml b/src/backend/services/api-gateway/config/insight.yaml deleted file mode 100644 index 2115d15d1..000000000 --- a/src/backend/services/api-gateway/config/insight.yaml +++ /dev/null @@ -1,118 +0,0 @@ -# Insight API Gateway Configuration -# -# Environment variable overrides: APP__gears____config__ -# Example: APP__gears__oidc-authn-plugin__config__issuer_url=https://dev-123.okta.com/oauth2/default - -server: - home_dir: "/app/data" - -logging: - # Console-only logging (stdout → K8s log collector → Loki). - # File logging disabled — container filesystem is ephemeral. - default: - console_level: info - api-gateway: - console_level: debug - -gears: - api-gateway: - config: - bind_addr: "0.0.0.0:8080" - enable_docs: false - cors_enabled: true - prefix_path: "/api" - openapi: - title: "Insight API" - version: "0.1.0" - description: "Insight Platform API" - defaults: - body_limit_bytes: 16777216 # 16 MB - rate_limit: - rps: 100 - burst: 50 - in_flight: 32 - - # Set to true to disable auth for local development. - # When false, OIDC plugin validates JWT tokens. - auth_disabled: false - - gear-orchestrator: - config: {} - - grpc-hub: - config: - listen_addr: "uds:///tmp/insight-grpc" - - # ─── Authentication ───────────────────────────────────────── - authn-resolver: - config: - vendor: "hyperspot" - - # OIDC/JWT plugin — validates bearer tokens against IdP (Okta, Keycloak, etc.) - # Required env vars for production: - # APP__gears__oidc-authn-plugin__config__issuer_url - # APP__gears__oidc-authn-plugin__config__audience - oidc-authn-plugin: - config: - vendor: "hyperspot" - priority: 50 - issuer_url: "${OIDC_ISSUER_URL}" # e.g., https://dev-12345.okta.com/oauth2/default - audience: "${OIDC_AUDIENCE}" # e.g., api://insight - # jwks_url: "" # Override if JWKS URL differs from {issuer}/v1/keys - jwks_refresh_interval_seconds: 300 - tenant_claim: "tenant_id" # JWT claim containing tenant UUID - subject_type: "user" - leeway_seconds: 60 - - # ─── Auth Info (public endpoint for frontend) ─────────────── - # NOTE: issuer_url MUST match oidc-authn-plugin.config.issuer_url above. - # Both are set from the same OIDC_ISSUER_URL env var / Helm oidc.issuer value. - auth-info: - config: - issuer_url: "${OIDC_ISSUER_URL}" - client_id: "${OIDC_CLIENT_ID}" - redirect_uri: "${OIDC_REDIRECT_URI}" - # Space-separated. IdP-specific — see AuthInfoConfig.scopes docs. - # Without a resource scope, Entra defaults the access token audience - # to Microsoft Graph and the gateway rejects it. - scopes: "${OIDC_SCOPES}" - - # ─── Authorization ────────────────────────────────────────── - authz-resolver: - config: - vendor: "hyperspot" - - # Static authz plugin (dev stub — allows all, scoped to tenant). - # Replace with insight-authz-plugin for org-tree + RBAC in production. - static-authz-plugin: - config: - vendor: "hyperspot" - priority: 100 - - # ─── Tenant Resolution ───────────────────────────────────── - tenant-resolver: - config: - vendor: "hyperspot" - - # Zero-config in the gears-rust split: the plugin derives the tenant from - # the SecurityContext at request time (JWT claims when auth is enabled, or - # toolkit-security's DEFAULT_TENANT_ID when auth_disabled=true). - single-tenant-tr-plugin: - config: - vendor: "hyperspot" - priority: 20 - - # ─── Reverse Proxy ───────────────────────────────────────── - # Routes requests to downstream services after authentication. - # Each route strips its prefix and forwards to the upstream URL. - proxy: - config: - routes: - - prefix: "/analytics" - upstream: "${PROXY_ANALYTICS_URL:=http://analytics:8081}" - -# ─── Local development ────────────────────────────────────── -# To run without OIDC (auth disabled), override: -# gears.api-gateway.config.auth_disabled: true -# Or use the no-auth config: -# insight-api-gateway run -c config/no-auth.yaml diff --git a/src/backend/services/api-gateway/config/no-auth.yaml b/src/backend/services/api-gateway/config/no-auth.yaml deleted file mode 100644 index fb9c298bb..000000000 --- a/src/backend/services/api-gateway/config/no-auth.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# Insight API Gateway — Local Development (No Auth) -# -# Usage: insight-api-gateway run -c config/no-auth.yaml -# -# All requests get a default SecurityContext (root access). -# Do NOT use in production. - -server: - home_dir: "~/.insight" - -logging: - default: - console_level: debug - -gears: - api-gateway: - config: - bind_addr: "0.0.0.0:8080" - enable_docs: true - cors_enabled: true - prefix_path: "/api" - openapi: - title: "Insight API (dev)" - version: "0.1.0-dev" - description: "Insight Platform API — auth disabled" - auth_disabled: true - - auth-info: - config: - issuer_url: "" - client_id: "" - redirect_uri: "http://localhost:3000/callback" - - # The oidc-authn-plugin gear is registered in the binary regardless of - # whether auth is in use. It rejects an empty issuer_url at startup, so - # we hand it a placeholder URL that is never actually contacted because - # api-gateway.auth_disabled=true short-circuits the validator. Replace - # with a real issuer when flipping AUTH_DISABLED off. - oidc-authn-plugin: - config: - issuer_url: "https://no-auth.local/oauth2/default" - - gear-orchestrator: - config: {} - - grpc-hub: - config: - listen_addr: "uds:///tmp/insight-grpc" - - authn-resolver: - config: - vendor: "hyperspot" - - authz-resolver: - config: - vendor: "hyperspot" - - static-authz-plugin: - config: - vendor: "hyperspot" - priority: 100 - - tenant-resolver: - config: - vendor: "hyperspot" - - # Zero-config in the gears-rust split: the plugin now derives the tenant - # from the SecurityContext at request time. With api-gateway.auth_disabled, - # toolkit-security injects DEFAULT_TENANT_ID (00000000-df51-5b42-9538-…). - single-tenant-tr-plugin: - config: - vendor: "hyperspot" - priority: 20 - - proxy: - config: - routes: - - prefix: "/analytics" - upstream: "http://analytics:8081" - - prefix: "/identity" - upstream: "http://identity:8082" diff --git a/src/backend/services/api-gateway/helm/Chart.yaml b/src/backend/services/api-gateway/helm/Chart.yaml deleted file mode 100644 index 8da95bcd4..000000000 --- a/src/backend/services/api-gateway/helm/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: insight-api-gateway -description: Insight API Gateway with OIDC authentication -version: 0.1.0 -appVersion: "2026.07.17.12.14-96a3da1" -type: application diff --git a/src/backend/services/api-gateway/helm/templates/_helpers.tpl b/src/backend/services/api-gateway/helm/templates/_helpers.tpl deleted file mode 100644 index b3d8469e1..000000000 --- a/src/backend/services/api-gateway/helm/templates/_helpers.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{{- define "insight-api-gateway.fullname" -}} -{{ .Release.Name }}-api-gateway -{{- end }} - -{{- define "insight-api-gateway.labels" -}} -app.kubernetes.io/name: api-gateway -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{- define "insight-api-gateway.selectorLabels" -}} -app.kubernetes.io/name: api-gateway -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} diff --git a/src/backend/services/api-gateway/helm/templates/configmap.yaml b/src/backend/services/api-gateway/helm/templates/configmap.yaml deleted file mode 100644 index 7d42c94b4..000000000 --- a/src/backend/services/api-gateway/helm/templates/configmap.yaml +++ /dev/null @@ -1,132 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "insight-api-gateway.fullname" . }}-config - labels: - {{- include "insight-api-gateway.labels" . | nindent 4 }} -data: - gateway.yaml: | - server: - home_dir: "/app/data" - - logging: - default: - console_level: info - - gears: - api-gateway: - config: - bind_addr: "0.0.0.0:{{ .Values.service.port }}" - auth_disabled: {{ .Values.authDisabled }} - cors_enabled: {{ .Values.gateway.corsEnabled }} - prefix_path: {{ .Values.gateway.prefixPath | quote }} - enable_docs: {{ .Values.gateway.enableDocs }} - openapi: - title: "Insight API" - version: "0.1.0" - description: "Insight Platform API" - defaults: - body_limit_bytes: 16777216 - rate_limit: - rps: {{ .Values.gateway.rateLimit.rps }} - burst: {{ .Values.gateway.rateLimit.burst }} - in_flight: {{ .Values.gateway.rateLimit.inFlight }} - - gear-orchestrator: - config: {} - - grpc-hub: - config: - listen_addr: "uds:///tmp/insight-grpc" - - authn-resolver: - config: - vendor: "hyperspot" - - oidc-authn-plugin: - config: - vendor: "hyperspot" - priority: 50 - {{- if .Values.authDisabled }} - # Auth disabled (LOCAL DEV ONLY): the oidc-authn-plugin still - # parses its config schema, so we pass a syntactically valid - # but never-fetched issuer URL and a sentinel audience. The - # plugin short-circuits before issuing any HTTP request when - # auth_disabled=true, so the example.com hostname is never - # actually resolved or contacted. - issuer_url: "https://disabled.example.com" - audience: "disabled" - {{- else if not .Values.oidc.existingSecret }} - # No existingSecret → operator MUST supply inline values. - issuer_url: {{ required "oidc.issuer is required when authDisabled=false and oidc.existingSecret is not set" .Values.oidc.issuer | quote }} - audience: {{ required "oidc.audience is required when authDisabled=false and oidc.existingSecret is not set" .Values.oidc.audience | quote }} - {{- end }} - # When existingSecret is set, OIDC env vars come from envFrom - # at pod-spec level (see deployment.yaml) — no inline values here. - jwks_refresh_interval_seconds: 300 - tenant_claim: "tenant_id" - subject_type: "user" - leeway_seconds: 60 - - auth-info: - config: - {{- if .Values.authDisabled }} - # Auth disabled (LOCAL DEV ONLY) — see issuer comment above. - issuer_url: "" - client_id: "" - redirect_uri: "" - {{- else if not .Values.oidc.existingSecret }} - issuer_url: {{ required "oidc.issuer is required when authDisabled=false and oidc.existingSecret is not set" .Values.oidc.issuer | quote }} - client_id: {{ required "oidc.clientId is required when authDisabled=false and oidc.existingSecret is not set" .Values.oidc.clientId | quote }} - redirect_uri: {{ required "oidc.redirectUri is required when authDisabled=false and oidc.existingSecret is not set" .Values.oidc.redirectUri | quote }} - {{- end }} - # OIDC scopes as space-separated string. The auth-info module's - # config struct has `scopes: String` (split into Vec at - # runtime via .split_whitespace()) — emitting a YAML sequence - # here would cause `serde::de::Error: invalid type: sequence, - # expected a string` on api-gateway init when running with - # authDisabled=true (no Secret mounted) or with a BYO - # `oidc.existingSecret` that does NOT include the - # `APP__gears__auth-info__config__scopes` env-var override. - # In Secret-injected paths the env-var still wins (envFrom - # overrides envFrom-loaded YAML); this fallback covers the - # configmap-only paths. - scopes: {{ .Values.oidc.scopes | default "openid profile email" | quote }} - - authz-resolver: - config: - vendor: "hyperspot" - - static-authz-plugin: - config: - vendor: "hyperspot" - priority: 100 - - tenant-resolver: - config: - vendor: "hyperspot" - - # Zero-config in the gears-rust split: the plugin derives the tenant - # from the SecurityContext at request time (JWT claims when auth is - # enabled, or toolkit-security's DEFAULT_TENANT_ID when - # authDisabled=true). The umbrella's `global.tenantDefaultId` is - # therefore no longer consumed here. - single-tenant-tr-plugin: - config: - vendor: "hyperspot" - priority: 20 - - proxy: - config: - routes: - {{- /* `tpl` evaluates the upstream string as a Helm template so the - umbrella can ship release-name-derived URLs (e.g. `http://{{ "{{" }} .Release.Name {{ "}}" }}-…`) - without hardcoding `insight-` prefixes. Literal URLs pass - through unchanged. We capture the parent context as $ctx - because `range` rebinds `$` for each item. */ -}} - {{- $ctx := . }} - {{- range .Values.proxy.routes }} - - prefix: {{ .prefix | quote }} - upstream: {{ tpl .upstream $ctx | quote }} - public: {{ .public | default false }} - {{- end }} diff --git a/src/backend/services/api-gateway/helm/templates/deployment.yaml b/src/backend/services/api-gateway/helm/templates/deployment.yaml deleted file mode 100644 index d682f7a34..000000000 --- a/src/backend/services/api-gateway/helm/templates/deployment.yaml +++ /dev/null @@ -1,81 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "insight-api-gateway.fullname" . }} - labels: - {{- include "insight-api-gateway.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.replicaCount }} - selector: - matchLabels: - {{- include "insight-api-gateway.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "insight-api-gateway.selectorLabels" . | nindent 8 }} - annotations: - checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - {{- if and (not .Values.authDisabled) (not .Values.oidc.existingSecret) }} - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} - {{- end }} - {{- with .Values.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.global }} - {{- with .imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- end }} - # Suppress the legacy `_SERVICE_HOST/PORT/…` Docker-link env-vars - # kubelet auto-injects for every Service in the namespace at pod-start. - # We discover dependencies via DNS (`*.svc.cluster.local`); the env-var - # mechanism only adds noise (~100+ vars in this namespace), creates - # startup-ordering coupling, and confuses `env` output by colliding - # with our own `APP__*` config-prefix scheme. - enableServiceLinks: false - terminationGracePeriodSeconds: 60 - securityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 1000 - containers: - - name: api-gateway - image: "{{ required "image.repository is required" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - command: ["/app/insight-api-gateway"] - args: ["-c", "/app/config/gateway.yaml", "run"] - volumeMounts: - - name: gateway-config - mountPath: /app/config/gateway.yaml - subPath: gateway.yaml - readOnly: true - ports: - - name: http - containerPort: {{ .Values.service.port }} - protocol: TCP - envFrom: - - configMapRef: - name: {{ include "insight-api-gateway.fullname" . }}-config - {{- if not .Values.authDisabled }} - - secretRef: - name: {{ .Values.oidc.existingSecret | default (printf "%s-oidc" (include "insight-api-gateway.fullname" .)) }} - {{- end }} - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: false - {{- with .Values.livenessProbe }} - livenessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.readinessProbe }} - readinessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - resources: - {{- toYaml .Values.resources | nindent 12 }} - volumes: - - name: gateway-config - configMap: - name: {{ include "insight-api-gateway.fullname" . }}-config diff --git a/src/backend/services/api-gateway/helm/templates/secret.yaml b/src/backend/services/api-gateway/helm/templates/secret.yaml deleted file mode 100644 index 0cc76b25d..000000000 --- a/src/backend/services/api-gateway/helm/templates/secret.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- if and (not .Values.authDisabled) (not .Values.oidc.existingSecret) }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "insight-api-gateway.fullname" . }}-oidc - labels: - {{- include "insight-api-gateway.labels" . | nindent 4 }} -type: Opaque -stringData: - APP__gears__oidc-authn-plugin__config__issuer_url: {{ required "oidc.issuer is required when auth is enabled and oidc.existingSecret is not set" .Values.oidc.issuer | quote }} - APP__gears__oidc-authn-plugin__config__audience: {{ .Values.oidc.audience | quote }} - APP__gears__auth-info__config__issuer_url: {{ required "oidc.issuer is required when auth is enabled and oidc.existingSecret is not set" .Values.oidc.issuer | quote }} - APP__gears__auth-info__config__client_id: {{ required "oidc.clientId is required when auth is enabled and oidc.existingSecret is not set" .Values.oidc.clientId | quote }} - APP__gears__auth-info__config__redirect_uri: {{ required "oidc.redirectUri is required when auth is enabled and oidc.existingSecret is not set" .Values.oidc.redirectUri | quote }} - APP__gears__auth-info__config__scopes: {{ required "oidc.scopes is required when auth is enabled and oidc.existingSecret is not set (space-separated, IdP-specific)" .Values.oidc.scopes | quote }} - {{- if .Values.oidc.jwksUrl }} - APP__gears__oidc-authn-plugin__config__jwks_url: {{ .Values.oidc.jwksUrl | quote }} - {{- end }} -{{- end }} diff --git a/src/backend/services/api-gateway/helm/values.yaml b/src/backend/services/api-gateway/helm/values.yaml deleted file mode 100644 index 50b75f48f..000000000 --- a/src/backend/services/api-gateway/helm/values.yaml +++ /dev/null @@ -1,89 +0,0 @@ -replicaCount: 1 - -podAnnotations: {} - -image: - repository: ghcr.io/constructorfabric/insight-api-gateway - tag: "" # MUST be set by the operator. No `:latest` default. - pullPolicy: IfNotPresent - -service: - type: ClusterIP - port: 8080 - -ingress: - enabled: true - className: nginx - annotations: {} - host: "" # Leave empty for local (no host-based routing) - tls: - enabled: false - secretName: "" - -resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 256Mi - -# OIDC configuration (required for production) -# -# Two modes: -# 1. Pre-provisioned Secret (recommended): set oidc.existingSecret to the -# name of a K8s Secret containing all OIDC env vars (see secrets/oidc.yaml.example). -# Values below are ignored. -# 2. Inline values: set oidc.issuer / clientId / redirectUri / audience directly. -# The chart will create its own Secret from these values. Suitable for local dev. -oidc: - existingSecret: "" # Name of pre-provisioned Secret. When set, inline values below are ignored. - issuer: "" # e.g., https://dev-12345.okta.com/oauth2/default - audience: "" # e.g., api://insight - clientId: "" # Frontend (public) OIDC client ID - redirectUri: "" # e.g., https://insight.example.com/callback - # Space-separated OIDC scopes the SPA should request. IdP-specific: - # Entra v2 single-app: "openid profile email api:///Access.Default" - # Okta: "openid profile email ." - # Without a resource scope, Entra defaults the audience to Microsoft Graph. - scopes: "" - # jwksUrl: "" # Override if JWKS URL differs from {issuer}/v1/keys - -# Set to true to disable authentication (local development only) -authDisabled: false - -# API Gateway settings -gateway: - prefixPath: "/api" - corsEnabled: true - enableDocs: false # Set true for dev; disabled by default for production - rateLimit: - rps: 100 - burst: 50 - inFlight: 32 - -# Reverse proxy routes. `upstream` is a templated string — `tpl` is -# applied at chart-render time, so `{{ .Release.Name }}` resolves to -# the actual umbrella release name. Operator can also pass a literal -# URL to point at an external service. -proxy: - routes: [] - -# Health probes -# Provided by gears-rust api-gateway module: -# GET /health → {"status":"healthy","timestamp":"..."} (public, no auth) -# GET /healthz → "ok" (public, no auth) -# No separate /ready endpoint — gateway is stateless, healthy = ready. -livenessProbe: - httpGet: - path: /health - port: http - initialDelaySeconds: 5 - periodSeconds: 10 - -readinessProbe: - httpGet: - path: /health - port: http - initialDelaySeconds: 5 - periodSeconds: 5 diff --git a/src/backend/services/api-gateway/secrets/.gitignore b/src/backend/services/api-gateway/secrets/.gitignore deleted file mode 100644 index d85764433..000000000 --- a/src/backend/services/api-gateway/secrets/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Real secrets — never commit -*.yaml -!*.yaml.example -!.gitignore diff --git a/src/backend/services/api-gateway/secrets/oidc.yaml.example b/src/backend/services/api-gateway/secrets/oidc.yaml.example deleted file mode 100644 index 447af8d9d..000000000 --- a/src/backend/services/api-gateway/secrets/oidc.yaml.example +++ /dev/null @@ -1,35 +0,0 @@ -# Insight API Gateway — OIDC Secret (Okta, Authorization Code + PKCE) -# -# Keys are API Gateway env-var overrides (APP____config__). -# They are consumed via `envFrom: secretRef` in the Helm chart when -# `oidc.existingSecret` points to this Secret. -# -# Usage: -# 1. Copy this file to oidc.yaml (gitignored): -# cp oidc.yaml.example oidc.yaml -# 2. Fill in real Okta values. -# 3. Apply: -# kubectl apply -n insight -f oidc.yaml -# 4. Reference in Helm: -# --set oidc.existingSecret=insight-oidc -# -apiVersion: v1 -kind: Secret -metadata: - name: insight-oidc - labels: - app.kubernetes.io/part-of: insight - app.kubernetes.io/component: api-gateway - annotations: - insight.cyberfabric.com/component: api-gateway -type: Opaque -stringData: - # ─── oidc-authn-plugin (validates bearer tokens) ─────────────── - APP__gears__oidc-authn-plugin__config__issuer_url: "https://integrator-4985807.okta.com/oauth2/default" - APP__gears__oidc-authn-plugin__config__audience: "api://default" - - # ─── auth-info (public GET /api/v1/auth/config for the SPA) ──── - # issuer_url MUST match the one above. - APP__gears__auth-info__config__issuer_url: "https://integrator-4985807.okta.com/oauth2/default" - APP__gears__auth-info__config__client_id: "CHANGE_ME" # Okta SPA app client ID (PKCE, no secret) - APP__gears__auth-info__config__redirect_uri: "http://CHANGE_ME/callback" # e.g., http://192.168.44.36/callback diff --git a/src/backend/services/api-gateway/specs/DESIGN.md b/src/backend/services/api-gateway/specs/DESIGN.md deleted file mode 100644 index e4a97e231..000000000 --- a/src/backend/services/api-gateway/specs/DESIGN.md +++ /dev/null @@ -1,417 +0,0 @@ ---- -status: proposed -date: 2026-04-06 ---- - -# Technical Design -- API Gateway - -- [ ] `p1` - **ID**: `cpt-insightspec-design-api-gateway` - - - -- [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) - - [3.8 Deployment Topology](#38-deployment-topology) -- [4. Additional Context](#4-additional-context) - - [JWKS Key Lifecycle](#jwks-key-lifecycle) - - [JWT Claim Mapping](#jwt-claim-mapping) - - [Configuration Layering](#configuration-layering) -- [5. Traceability](#5-traceability) - - - -## 1. Architecture Overview - -### 1.1 Architectural Vision - -The API Gateway is the HTTP entry point for all Insight backend services. It is a cyberfabric-core ModKit server binary that links system modules (api-gateway, authn-resolver, authz-resolver, tenant-resolver) with an Insight-specific OIDC authentication plugin and a public auth-info endpoint. - -The gateway validates every inbound request via JWT bearer tokens against the customer's OIDC provider (Okta, Keycloak, Auth0), enforces tenant isolation and RBAC via the authorization pipeline, and routes authenticated requests to service modules. It exposes OpenAPI documentation, CORS, per-route rate limiting, and RFC 9457 error responses out of the box via cyberfabric-core infrastructure. - -The OIDC plugin is built as a reusable `authn-resolver` plugin using `modkit-auth` JWT/JWKS primitives, suitable for upstreaming to cyberfabric-core. - -### 1.2 Architecture Drivers - -#### Functional Drivers - -| Requirement | Design Response | -|-------------|------------------| -| `cpt-insightspec-fr-be-oidc-auth` | OIDC plugin validates JWT tokens via JWKS key discovery; no bundled IdP | -| `cpt-insightspec-fr-be-health-checks` | Cyberfabric api-gateway exposes `/health` and `/ready` | -| `cpt-insightspec-fr-be-rbac` | AuthZ resolver pipeline enforces role-based access | -| `cpt-insightspec-fr-be-visibility-policy` | AuthZ plugin returns org-scoped constraints per request | - -#### NFR Allocation - -| NFR ID | NFR Summary | Allocated To | Design Response | Verification Approach | -|--------|-------------|--------------|-----------------|----------------------| -| `cpt-insightspec-nfr-be-rate-limiting` | Per-route rate limiting | api-gateway (governor) | Configurable RPS, burst, max in-flight per operation | Load test; verify 429 at threshold | -| `cpt-insightspec-nfr-be-api-versioning` | Versioned endpoints | api-gateway prefix_path | All routes under `/api/v1/` | Deploy v2; verify v1 still works | -| `cpt-insightspec-nfr-be-api-conventions` | RFC 9457, OData, pagination | api-gateway middleware | Standard error format, consistent response envelopes | Integration tests verify format | -| `cpt-insightspec-nfr-be-graceful-shutdown` | Zero message loss on deploy | ModKit CancellationToken | SIGTERM → drain → close | Rolling deploy test | -| `cpt-insightspec-nfr-be-tenant-isolation` | Tenant data isolation | authn + authz pipeline | SecurityContext carries tenant_id from JWT → AccessScope | Cross-tenant access test | - -### 1.3 Architecture Layers - -```mermaid -graph TB - subgraph Client - FE[Frontend / API Consumer] - end - - subgraph Gateway["API Gateway (Axum)"] - direction TB - MW1[Request ID + Tracing] - MW2[Rate Limiting] - MW3[CORS] - MW4["Auth Middleware"] - ROUTE[Route Matching] - end - - subgraph AuthPipeline["Authentication Pipeline"] - AUTHN_GW[authn-resolver gateway] - OIDC[oidc-authn-plugin] - JWKS[JWKS Key Provider] - end - - subgraph AuthzPipeline["Authorization Pipeline"] - AUTHZ_GW[authz-resolver gateway] - AUTHZ_P[static-authz-plugin] - end - - subgraph TenantPipeline["Tenant Resolution"] - TR_GW[tenant-resolver gateway] - TR_P[single-tenant-tr-plugin] - end - - subgraph Public["Public Endpoints (no auth)"] - AUTH_INFO["GET /auth/config"] - end - - subgraph Modules["Service Modules (future)"] - ANALYTICS[Analytics API] - CONNECTORS[Connector Manager] - OTHER[...] - end - - subgraph External["External"] - IDP[OIDC Provider
Okta / Keycloak / Auth0] - end - - FE -->|Bearer token| MW1 - MW1 --> MW2 --> MW3 --> MW4 - MW4 -->|extract token| AUTHN_GW - AUTHN_GW --> OIDC - OIDC -->|validate JWT| JWKS - JWKS -->|fetch keys| IDP - MW4 -->|SecurityContext| AUTHZ_GW - AUTHZ_GW --> AUTHZ_P - MW4 --> TR_GW --> TR_P - MW4 --> ROUTE - ROUTE --> Modules - ROUTE --> AUTH_INFO -``` - -- [ ] `p1` - **ID**: `cpt-insightspec-tech-api-gateway-layers` - -| Layer | Responsibility | Technology | -|-------|---------------|------------| -| HTTP Server | Request handling, routing, middleware | Axum (via cyberfabric api-gateway module) | -| Authentication | JWT validation, JWKS discovery | oidc-authn-plugin + modkit-auth | -| Authorization | RBAC + org scoping | authz-resolver + static-authz-plugin (custom plugin later) | -| Tenant Resolution | Workspace isolation | tenant-resolver + single-tenant-tr-plugin | -| Public Endpoints | Unauthenticated routes | auth-info module (GET /auth/config) | - -## 2. Principles & Constraints - -### 2.1 Design Principles - -#### Reuse cyberfabric-core, Don't Reinvent - -- [ ] `p1` - **ID**: `cpt-insightspec-principle-gw-reuse-cyberfabric` - -The gateway is a thin binary that links cyberfabric-core system modules. All HTTP infrastructure (routing, middleware, OpenAPI, rate limiting) comes from the api-gateway module. Authentication and authorization use the existing resolver/plugin pattern. Only Insight-specific logic (OIDC plugin, auth-info endpoint) is custom code. - -**Why**: Reduces maintenance burden. Bug fixes and features in cyberfabric-core benefit Insight automatically. - -#### Public by Exception - -- [ ] `p1` - **ID**: `cpt-insightspec-principle-gw-public-by-exception` - -All routes require authentication by default (`require_auth_by_default: true`). Public routes must explicitly call `.public()` on the `OperationBuilder`. Only `/auth/config` is public. - -**Why**: Prevents accidental exposure of authenticated endpoints. New modules get auth for free. - -#### Plugin-Based Authentication - -- [ ] `p1` - **ID**: `cpt-insightspec-principle-gw-plugin-auth` - -Authentication is implemented as a pluggable `authn-resolver` plugin, not hardcoded in the gateway. The OIDC plugin can be swapped for a static plugin (dev), a different OIDC provider, or a custom enterprise auth plugin without changing the gateway binary. - -**Why**: Customers have different IdPs. The plugin pattern keeps the gateway generic. - -### 2.2 Constraints - -#### OIDC Providers Only - -- [ ] `p1` - **ID**: `cpt-insightspec-constraint-gw-oidc-only` - -The gateway supports only OIDC-compliant identity providers that expose JWKS endpoints. SAML, LDAP direct auth, and API key authentication are not supported. - -#### Okta JWKS Default - -- [ ] `p2` - **ID**: `cpt-insightspec-constraint-gw-okta-jwks-default` - -The OIDC plugin defaults to Okta's JWKS URL convention (`{issuer}/v1/keys`). Non-Okta providers (Keycloak, Auth0) must set `jwks_url` explicitly in configuration. - -## 3. Technical Architecture - -### 3.1 Domain Model - -```mermaid -classDiagram - class SecurityContext { - +UUID subject_id - +String subject_type - +UUID subject_tenant_id - +Vec~String~ token_scopes - +SecretString bearer_token - } - - class AuthInfoResponse { - +String issuer_url - +String client_id - +String redirect_uri - +Vec~String~ scopes - +String response_type - } - - class OidcAuthnPluginConfig { - +String issuer_url - +String audience - +String jwks_url - +String tenant_claim - +String subject_type - +i64 leeway_seconds - } - - class OidcService { - +validate_token(token) SecurityContext - } - - class AuthInfoConfig { - +String issuer_url - +String client_id - +String redirect_uri - +Vec~String~ scopes - } - - OidcService --> SecurityContext : produces - OidcService --> OidcAuthnPluginConfig : configured by - AuthInfoResponse --> AuthInfoConfig : derived from -``` - -### 3.2 Component Model - -#### OIDC AuthN Plugin - -- [ ] `p1` - **ID**: `cpt-insightspec-component-gw-oidc-plugin` - -##### Why this component exists - -Cyberfabric-core ships only a static (dev) authn plugin. Insight needs production JWT validation against real OIDC providers. - -##### Responsibility scope - -Validate JWT bearer tokens using JWKS key discovery (via `modkit-auth`). Extract `sub`, tenant, and scopes from claims. Build `SecurityContext`. Register as `authn-resolver` plugin via GTS types-registry. - -##### Responsibility boundaries - -Does NOT handle OIDC Authorization Code flow (that's the frontend). Does NOT support client credentials exchange (use static plugin for S2S). Does NOT manage users or sessions. - -##### Related components (by ID) - -- `cpt-insightspec-component-gw-auth-info` -- provides: frontend uses auth-info config to redirect to the same IdP this plugin validates against - -#### Auth Info Module - -- [ ] `p2` - **ID**: `cpt-insightspec-component-gw-auth-info` - -##### Why this component exists - -The frontend needs to know where to redirect for OIDC login. This config cannot be hardcoded in the frontend build because different deployments use different IdPs. - -##### Responsibility scope - -Serve a single public endpoint (`GET /auth/config`) returning OIDC provider details (issuer URL, client ID, redirect URI, scopes, response type). - -##### Responsibility boundaries - -Does NOT perform authentication. Does NOT redirect users. Does NOT store tokens. The endpoint is read-only configuration. - -##### Related components (by ID) - -- `cpt-insightspec-component-gw-oidc-plugin` -- depends on: serves the same issuer_url that the OIDC plugin validates against - -### 3.3 API Contracts - -- [ ] `p2` - **ID**: `cpt-insightspec-interface-gw-auth-config` - -- **Technology**: REST/JSON -- **Location**: `GET /auth/config` (public, no auth) - -| Method | Path | Description | Auth | -|--------|------|-------------|------| -| `GET` | `/auth/config` | OIDC configuration for frontend | Public | - -**Response** (200): - -```json -{ - "issuer_url": "https://dev-12345.okta.com/oauth2/default", - "client_id": "0oa1b2c3d4e5f6g7h8i9", - "redirect_uri": "https://insight.example.com/callback", - "scopes": ["openid", "profile", "email"], - "response_type": "code" -} -``` - -### 3.4 Internal Dependencies - -| Dependency Module | Interface Used | Purpose | -|-------------------|----------------|----------| -| api-gateway (cyberfabric) | Axum server, middleware pipeline | HTTP infrastructure | -| authn-resolver (cyberfabric) | Plugin gateway | Delegates auth to OIDC plugin | -| authz-resolver (cyberfabric) | Plugin gateway | Delegates authz to static/custom plugin | -| tenant-resolver (cyberfabric) | Plugin gateway | Resolves tenant from SecurityContext | -| types-registry (cyberfabric) | Plugin discovery | OIDC plugin registers via GTS | -| modkit-auth (cyberfabric) | JwksKeyProvider, validate_claims | JWT/JWKS primitives | -| modkit-http (cyberfabric) | HttpClient | JWKS endpoint HTTP calls | - -### 3.5 External Dependencies - -| Dependency | Protocol | Purpose | -|-----------|----------|---------| -| OIDC Provider (Okta/Keycloak/Auth0) | HTTPS (JWKS endpoint) | JWT key discovery and validation | - -### 3.6 Interactions & Sequences - -#### Authenticated Request Flow - -**ID**: `cpt-insightspec-seq-gw-auth-flow` - -```mermaid -sequenceDiagram - participant C as Client - participant GW as API Gateway - participant OIDC as OIDC Plugin - participant JWKS as JWKS Cache - participant AZ as AuthZ Plugin - participant M as Service Module - - C->>GW: GET /api/v1/analytics/metrics (Bearer token) - GW->>GW: Rate limit check - GW->>OIDC: authenticate(bearer_token) - OIDC->>JWKS: validate_and_decode(token) - JWKS-->>OIDC: (header, claims) - OIDC->>OIDC: validate_claims(iss, aud, exp) - OIDC->>OIDC: extract sub → UUID v5(issuer#sub) - OIDC->>OIDC: extract tenant, scopes - OIDC-->>GW: SecurityContext - GW->>AZ: evaluate(subject, action, resource) - AZ-->>GW: EvaluationResponse(allow, constraints) - GW->>M: Forward request with SecurityContext - M-->>GW: Response - GW-->>C: 200 OK -``` - -#### Public Endpoint Flow - -```mermaid -sequenceDiagram - participant C as Frontend - participant GW as API Gateway - participant AI as Auth Info Module - - C->>GW: GET /auth/config (no token) - GW->>GW: Route marked public — skip auth - GW->>AI: handler() - AI-->>GW: AuthInfoResponse JSON - GW-->>C: 200 OK { issuer_url, client_id, ... } - C->>C: Construct OIDC authorize URL - C->>C: Redirect to IdP login -``` - -### 3.7 Database Schemas & Tables - -No database. The API Gateway is stateless. - -### 3.8 Deployment Topology - -- [ ] `p2` - **ID**: `cpt-insightspec-topology-gw` - -Deployed as a Kubernetes Deployment with per-service Helm chart (`services/api-gateway/helm/`). Scales horizontally via HPA. No persistent storage. - -Configuration via: -1. YAML config file (baked into image at `/etc/insight/`) -2. Environment variable overrides (`APP__gears____config__`) -3. Helm values → rendered as env vars in deployment template - -## 4. Additional Context - -### JWKS Key Lifecycle - -The OIDC plugin uses `modkit-auth::JwksKeyProvider` which: -1. Fetches JWKS keys on startup (logs warning if IdP unreachable, does not crash) -2. Caches keys in `ArcSwap` for lock-free reads -3. Refreshes keys on a configurable interval (default: 5 minutes) -4. Uses exponential backoff on refresh failures -5. On-demand refresh with cooldown when an unknown `kid` is encountered - -### JWT Claim Mapping - -| JWT Claim | SecurityContext Field | Logic | -|-----------|---------------------|-------| -| `sub` | `subject_id` | UUID v5 hash of `{issuer}#{sub}` — prevents cross-IdP collision | -| `scp` (array) | `token_scopes` | Okta convention; takes priority over `scope` | -| `scope` (string) | `token_scopes` | Standard OIDC; split by whitespace | -| (neither) | `token_scopes` | Empty vec (authz layer decides) | -| `{tenant_claim}` | `subject_tenant_id` | Configurable claim name; parsed as UUID; nil if missing | - -### Configuration Layering - -```text -Defaults (code) → YAML file (-c flag) → Env vars (APP__*) → CLI flags (-v) -``` - -Each layer overrides the previous. Environment variables use double-underscore nesting: `APP__gears__oidc-authn-plugin__config__issuer_url`. - -## 5. Traceability - -- **Backend PRD**: `docs/components/backend/specs/PRD.md` -- **Backend DESIGN**: `docs/components/backend/specs/DESIGN.md` - -This design directly addresses: - -- `cpt-insightspec-fr-be-oidc-auth` -- OIDC plugin validates JWT tokens -- `cpt-insightspec-fr-be-health-checks` -- api-gateway exposes /health, /ready -- `cpt-insightspec-nfr-be-rate-limiting` -- api-gateway governor rate limiter -- `cpt-insightspec-nfr-be-api-versioning` -- prefix_path /api/v1 -- `cpt-insightspec-nfr-be-api-conventions` -- RFC 9457 errors, OData conventions -- `cpt-insightspec-nfr-be-graceful-shutdown` -- ModKit CancellationToken -- `cpt-insightspec-nfr-be-tenant-isolation` -- SecurityContext carries tenant_id -- `cpt-insightspec-component-be-analytics-api` -- gateway routes to this (future) -- `cpt-insightspec-component-be-connector-manager` -- gateway routes to this (future) diff --git a/src/backend/services/api-gateway/src/auth_info.rs b/src/backend/services/api-gateway/src/auth_info.rs deleted file mode 100644 index e6cbfa822..000000000 --- a/src/backend/services/api-gateway/src/auth_info.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Auth info module — public endpoint that serves OIDC configuration to the frontend. -//! -//! `GET /v1/auth/config` — no authentication required. -//! -//! Returns the OIDC provider details the frontend needs to initiate the -//! Authorization Code flow with PKCE (redirect to login page, token exchange). - -use std::sync::{Arc, OnceLock}; - -use async_trait::async_trait; -use axum::http::{Method, StatusCode}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use toolkit::api::{OpenApiRegistry, OperationBuilder}; -use toolkit::context::GearCtx; -use toolkit::contracts::{Gear, RestApiCapability}; - -/// OIDC configuration served to the frontend. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthInfoResponse { - /// OIDC issuer URL (e.g., `https://dev-12345.okta.com/oauth2/default`). - pub issuer_url: String, - /// OIDC client ID for the frontend application. - pub client_id: String, - /// Redirect URI after login (frontend callback URL). - pub redirect_uri: String, - /// Scopes to request from the OIDC provider. - pub scopes: Vec, - /// OIDC response type (always "code" for Authorization Code flow). - pub response_type: String, -} - -/// Gear configuration (from YAML). -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct AuthInfoConfig { - /// OIDC issuer URL. Should match the OIDC plugin's `issuer_url`. - pub issuer_url: String, - /// OIDC client ID for the frontend (public client, no secret). - pub client_id: String, - /// Frontend callback URL after OIDC login. - pub redirect_uri: String, - /// Scopes to request, as a space-separated string (matches OAuth2's wire - /// format). Stored as `String` so the standard - /// `APP__gears__auth-info__config__scopes` env-var override works - /// without a custom Vec deserializer; split on whitespace when building - /// the response. IdP-specific: - /// Entra v2 single-app: "openid profile email api:///Access.Default" - /// Okta: "openid profile email ." - pub scopes: String, -} - -/// Auth info module — serves OIDC config to the frontend. -#[toolkit::gear( - name = "auth-info", - capabilities = [rest] -)] -pub struct AuthInfoModule { - config: OnceLock>, -} - -impl Default for AuthInfoModule { - fn default() -> Self { - Self { - config: OnceLock::new(), - } - } -} - -#[async_trait] -impl Gear for AuthInfoModule { - async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { - let config: AuthInfoConfig = ctx.config()?; - - if config.issuer_url.is_empty() { - tracing::warn!( - "auth-info: issuer_url is empty. \ - /auth/config endpoint will return empty OIDC config. \ - Set gears.auth-info.config.issuer_url." - ); - } - if config.scopes.split_whitespace().next().is_none() { - tracing::warn!( - "auth-info: scopes is empty. SPA will request no OIDC scopes \ - and IdPs will fall back to default audiences (Entra → Microsoft Graph), \ - producing access tokens the gateway can't validate. \ - Set gears.auth-info.config.scopes (space-separated)." - ); - } - - self.config - .set(Arc::new(config)) - .map_err(|_| anyhow::anyhow!("auth-info module already initialized"))?; - - Ok(()) - } -} - -impl RestApiCapability for AuthInfoModule { - fn register_rest( - &self, - _ctx: &GearCtx, - router: Router, - openapi: &dyn OpenApiRegistry, - ) -> anyhow::Result { - let config = self - .config - .get() - .ok_or_else(|| anyhow::anyhow!("auth-info not initialized"))? - .clone(); - - let response = AuthInfoResponse { - issuer_url: config.issuer_url.clone(), - client_id: config.client_id.clone(), - redirect_uri: config.redirect_uri.clone(), - scopes: config - .scopes - .split_whitespace() - .map(str::to_owned) - .collect(), - response_type: "code".to_owned(), - }; - - let handler = move || { - let resp = response.clone(); - async move { Json(resp) } - }; - - let router = OperationBuilder::new(Method::GET, "/v1/auth/config") - .summary("OIDC configuration for frontend") - .description("Returns OIDC provider details for the Authorization Code flow with PKCE. No authentication required.") - .public() - .json_response(StatusCode::OK, "OIDC configuration") - .standard_errors(openapi) - .handler(handler) - .register(router, openapi); - - tracing::info!("registered public endpoint: GET /v1/auth/config"); - Ok(router) - } -} diff --git a/src/backend/services/api-gateway/src/main.rs b/src/backend/services/api-gateway/src/main.rs deleted file mode 100644 index b642ca04d..000000000 --- a/src/backend/services/api-gateway/src/main.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Insight API Gateway -//! -//! Server binary that bootstraps the cyberfabric runtime with: -//! - API Gateway (HTTP server, `OpenAPI`, CORS, rate limiting, auth middleware) -//! - OIDC `AuthN` plugin (JWT validation against Okta/Keycloak/Auth0) -//! - `AuthZ` resolver (static plugin for now, custom org-tree plugin later) -//! - Tenant resolver (single-tenant plugin) -//! -//! # Configuration -//! -//! See `config/insight.yaml` for production config and `config/no-auth.yaml` for local dev. -//! -//! Usage: -//! insight-api-gateway run -c config/insight.yaml -//! insight-api-gateway check -c config/insight.yaml - -// Insight gears (compiled into the binary, registered via inventory). -mod auth_info; -mod proxy; - -// Link external modules via inventory — runtime discovers them automatically. -use api_gateway as _; -use authn_resolver as _; -use authz_resolver as _; -use gear_orchestrator as _; -use grpc_hub as _; -use oidc_authn_plugin as _; -use single_tenant_tr_plugin as _; -use static_authz_plugin as _; -use tenant_resolver as _; -use types_registry as _; - -use std::path::PathBuf; - -use anyhow::Result; -use clap::{Parser, Subcommand}; -use toolkit::bootstrap::{AppConfig, run_migrate, run_server}; - -/// Insight API Gateway — entry point for all backend services. -#[derive(Parser)] -#[command(name = "insight-api-gateway")] -#[command(about = "Insight platform API Gateway with OIDC authentication")] -#[command(version = env!("CARGO_PKG_VERSION"))] -struct Cli { - /// Path to YAML configuration file. - #[arg(short, long)] - config: Option, - - /// Print effective configuration and exit. - #[arg(long)] - print_config: bool, - - /// Increase log verbosity (-v = debug, -vv = trace). - #[arg(short, long, action = clap::ArgAction::Count)] - verbose: u8, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Start the API gateway server (default). - Run, - /// Validate configuration and exit. - Check, - /// Run database migrations and exit. - Migrate, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - - // Layered config: defaults → YAML → env (APP__*) → CLI overrides - let mut config = AppConfig::load_or_default(cli.config.as_ref())?; - config.apply_cli_overrides(cli.verbose); - - if cli.print_config { - println!("Effective configuration:\n{}", config.to_yaml()?); - return Ok(()); - } - - match cli.command.unwrap_or(Commands::Run) { - Commands::Run => run_server(config).await, - Commands::Migrate => run_migrate(config).await, - Commands::Check => Ok(()), - } -} diff --git a/src/backend/services/api-gateway/src/proxy.rs b/src/backend/services/api-gateway/src/proxy.rs deleted file mode 100644 index e497f63f3..000000000 --- a/src/backend/services/api-gateway/src/proxy.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Reverse proxy module — routes requests to upstream services. -//! -//! Reads route definitions from YAML config and registers catch-all -//! handlers that forward requests (with auth headers) to upstream services. -//! -//! Routes marked `public: true` skip authentication. -//! Routes with `public: false` (default) require a valid JWT — the gateway's -//! auth middleware validates the token before the request reaches the proxy. -//! -//! # Configuration -//! -//! ```yaml -//! modules: -//! proxy: -//! config: -//! routes: -//! - prefix: "/analytics" -//! upstream: "http://analytics:8081" -//! - prefix: "/public-api" -//! upstream: "http://public-service:8080" -//! public: true -//! ``` - -use std::sync::{Arc, OnceLock}; - -use async_trait::async_trait; -use axum::Router; -use axum::body::Body; -use axum::extract::Request; -use axum::http::{HeaderValue, Method, StatusCode}; -use axum::response::{IntoResponse, Response}; -use serde::Deserialize; -use toolkit::api::{OpenApiRegistry, OperationBuilder}; -use toolkit::context::GearCtx; -use toolkit::contracts::{Gear, RestApiCapability}; -use toolkit_canonical_errors::CanonicalError; - -/// Route definition: prefix → upstream. -#[derive(Debug, Clone, Deserialize)] -pub struct RouteConfig { - /// URL path prefix to match (e.g. "/analytics"). - pub prefix: String, - /// Upstream service base URL (e.g. `http://analytics:8081`). - pub upstream: String, - /// If true, this route does NOT require authentication. - /// Default: false (all routes require auth). - #[serde(default)] - pub public: bool, -} - -/// Gear configuration. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default)] -pub struct ProxyConfig { - pub routes: Vec, -} - -/// Shared state for proxy handlers. -#[derive(Debug, Clone)] -struct ProxyState { - client: reqwest::Client, - upstream: String, - prefix: String, -} - -/// Reverse proxy module. -#[toolkit::gear( - name = "proxy", - capabilities = [rest] -)] -pub struct ProxyModule { - config: OnceLock>, -} - -impl Default for ProxyModule { - fn default() -> Self { - Self { - config: OnceLock::new(), - } - } -} - -#[async_trait] -impl Gear for ProxyModule { - async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { - let config: ProxyConfig = ctx.config()?; - - for route in &config.routes { - tracing::info!( - prefix = %route.prefix, - upstream = %route.upstream, - public = route.public, - "proxy route configured" - ); - } - - if config.routes.is_empty() { - tracing::warn!("proxy: no routes configured — module will be inactive"); - } - - self.config - .set(Arc::new(config)) - .map_err(|_| anyhow::anyhow!("proxy module already initialized"))?; - - Ok(()) - } -} - -impl RestApiCapability for ProxyModule { - fn register_rest( - &self, - _ctx: &GearCtx, - mut router: Router, - openapi: &dyn OpenApiRegistry, - ) -> anyhow::Result { - let config = self - .config - .get() - .ok_or_else(|| anyhow::anyhow!("proxy module not initialized"))?; - - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(5)) - .build()?; - - for route in &config.routes { - let prefix = route.prefix.trim_end_matches('/').to_owned(); - let upstream = route.upstream.trim_end_matches('/').to_owned(); - - let state = Arc::new(ProxyState { - client: client.clone(), - upstream, - prefix: prefix.clone(), - }); - - let wildcard_path = format!("{prefix}/{{*rest}}"); - let desc = format!("Proxy → {}", route.upstream); - - let methods = [ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::PATCH, - ]; - - for method in methods { - let st = state.clone(); - let handler = move |req: Request| { - let st = st.clone(); - async move { proxy_handler(st, req).await } - }; - - router = if route.public { - register_public(router, openapi, method, &wildcard_path, &desc, handler) - } else { - register_authenticated(router, openapi, method, &wildcard_path, &desc, handler) - }; - } - - tracing::info!( - path = %wildcard_path, - public = route.public, - "registered proxy route" - ); - } - - tracing::warn!( - "authenticated 404 fallback not yet wired up against cf-gears-api-gateway — \ - unmatched paths return Axum's default 404 without auth checking" - ); - - Ok(router) - } -} - -/// Register an authenticated proxy route via `OperationBuilder`. -fn register_authenticated( - router: Router, - openapi: &dyn OpenApiRegistry, - method: Method, - path: &str, - summary: &str, - handler: H, -) -> Router -where - H: axum::handler::Handler + Clone, - T: 'static, -{ - OperationBuilder::new(method, path) - .summary(summary) - .authenticated() - .no_license_required() - .json_response(StatusCode::OK, "Proxied response") - .handler(handler) - .register(router, openapi) -} - -/// Register a public (no auth) proxy route via `OperationBuilder`. -fn register_public( - router: Router, - openapi: &dyn OpenApiRegistry, - method: Method, - path: &str, - summary: &str, - handler: H, -) -> Router -where - H: axum::handler::Handler + Clone, - T: 'static, -{ - OperationBuilder::new(method, path) - .summary(summary) - .public() - .json_response(StatusCode::OK, "Proxied response") - .handler(handler) - .register(router, openapi) -} - -/// Forward the request to the upstream service. -async fn proxy_handler(state: Arc, req: Request) -> Response { - match forward_request(&state, req).await { - Ok(resp) => resp, - Err(e) => { - tracing::error!( - upstream = %state.upstream, - error = %e, - "proxy request failed" - ); - CanonicalError::service_unavailable() - .with_detail("upstream service unavailable") - .create() - .into_response() - } - } -} - -/// Build and send the proxied request. -async fn forward_request( - state: &ProxyState, - req: Request, -) -> Result { - let (parts, body) = req.into_parts(); - - // Strip the proxy prefix from the path to get the upstream path. - // e.g. /analytics/v1/metrics → /v1/metrics - let original_path = parts.uri.path(); - let upstream_path = original_path - .strip_prefix(&state.prefix) - .unwrap_or(original_path); - - // Build upstream URI - let upstream_uri = if let Some(query) = parts.uri.query() { - format!("{}{upstream_path}?{query}", state.upstream) - } else { - format!("{}{upstream_path}", state.upstream) - }; - - // Read request body (max 16 MB) - let body_bytes = axum::body::to_bytes(body, 16 * 1024 * 1024).await?; - - // Build proxied request — forward end-to-end headers only - let mut upstream_req = state.client.request(parts.method.clone(), &upstream_uri); - - for (name, value) in &parts.headers { - if !is_hop_by_hop(name.as_str()) { - upstream_req = upstream_req.header(name, value); - } - } - - let upstream_resp = upstream_req.body(body_bytes).send().await?; - - // Convert reqwest response back to axum response — strip hop-by-hop headers - let status = StatusCode::from_u16(upstream_resp.status().as_u16())?; - let mut builder = Response::builder().status(status); - - for (name, value) in upstream_resp.headers() { - if !is_hop_by_hop(name.as_str()) { - builder = builder.header(name, value); - } - } - - let resp_headers = upstream_resp.headers().clone(); - let resp_body = upstream_resp.bytes().await?; - - let mut response = builder.body(Body::from(resp_body))?; - - if !resp_headers.contains_key("content-type") { - response - .headers_mut() - .insert("content-type", HeaderValue::from_static("application/json")); - } - - Ok(response) -} - -/// RFC 2616 §13.5.1 / RFC 7230 §6.1 — headers that are connection-scoped -/// and must not be forwarded by a proxy. -fn is_hop_by_hop(name: &str) -> bool { - matches!( - name, - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "host" - ) -} diff --git a/src/backend/services/authenticator/Cargo.toml b/src/backend/services/authenticator/Cargo.toml index d10797620..99281c0ee 100644 --- a/src/backend/services/authenticator/Cargo.toml +++ b/src/backend/services/authenticator/Cargo.toml @@ -48,7 +48,7 @@ tokio = { workspace = true } tokio-util = "0.7" async-trait = { workspace = true } futures = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["v5"] } chrono = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -60,11 +60,13 @@ openidconnect = "4" axum-extra = { version = "0.12", features = ["cookie"] } time = { workspace = true } -# Crypto: ES256 gateway-JWT minting/verification (§9.6 decision — ES256 over -# EdDSA for zero downstream-verifier friction), JWKS export, PKCE, CSPRNG. +# Crypto: ES256 gateway-JWT minting/verification (§9.6 — ES256: 64-byte +# signatures + fast verify; the downstream `cf-gears-oidc-authn-plugin` verifies +# EC keys natively via jsonwebtoken's JwkSet), JWKS export, PKCE, CSPRNG. # jsonwebtoken is a workspace dep (one version + one crypto provider, aws_lc_rs, # across the workspace — v10 panics if two providers are active under feature -# unification). +# unification). `p256` is also used for the ES256 service-token client +# assertions (RFC 7523), whose keys are the services' own EC identity keys. jsonwebtoken = { workspace = true } p256 = { version = "0.14", features = ["pkcs8", "pem", "ecdsa", "getrandom"] } sha2 = "0.10" diff --git a/src/backend/services/authenticator/Dockerfile b/src/backend/services/authenticator/Dockerfile index db4612183..73a98d786 100644 --- a/src/backend/services/authenticator/Dockerfile +++ b/src/backend/services/authenticator/Dockerfile @@ -19,8 +19,6 @@ WORKDIR /build 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 plugins/oidc-authn-plugin/Cargo.toml ./plugins/oidc-authn-plugin/Cargo.toml -COPY services/api-gateway/Cargo.toml ./services/api-gateway/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 @@ -33,8 +31,6 @@ 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 plugins/oidc-authn-plugin/src && echo "" > plugins/oidc-authn-plugin/src/lib.rs && \ - mkdir -p services/api-gateway/src && echo "fn main() {}" > services/api-gateway/src/main.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 && \ @@ -61,8 +57,7 @@ FROM debian:bookworm-slim ARG TARGETARCH ARG WATCHEXEC_VERSION=2.3.2 -# Runtime deps + watchexec (auto-reload wrapper in compose). See -# api-gateway/Dockerfile for rationale. +# Runtime deps + watchexec (auto-reload wrapper in compose). RUN set -eux; \ case "${TARGETARCH:-amd64}" in \ amd64) WX_ARCH=x86_64-unknown-linux-musl ;; \ diff --git a/src/backend/services/authenticator/helm/templates/_helpers.tpl b/src/backend/services/authenticator/helm/templates/_helpers.tpl index eaaa8dbb0..de972a549 100644 --- a/src/backend/services/authenticator/helm/templates/_helpers.tpl +++ b/src/backend/services/authenticator/helm/templates/_helpers.tpl @@ -11,3 +11,7 @@ app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/name: authenticator app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} + +{{- define "insight-authenticator.authnTlsSecret" -}} +{{- .Values.tlsDiscovery.certSecret | default (printf "%s-authn-tls-cert" (include "insight-authenticator.fullname" .)) -}} +{{- end }} diff --git a/src/backend/services/authenticator/helm/templates/authn-tls.yaml b/src/backend/services/authenticator/helm/templates/authn-tls.yaml new file mode 100644 index 000000000..f8301cf73 --- /dev/null +++ b/src/backend/services/authenticator/helm/templates/authn-tls.yaml @@ -0,0 +1,55 @@ +{{- if .Values.tlsDiscovery.enabled }} +{{- $fullname := include "insight-authenticator.fullname" . }} +{{- $svc := printf "%s.%s.svc" $fullname .Release.Namespace }} +# TLS discovery front for the authenticator's OIDC discovery + JWKS. Downstream +# verifiers (analytics / identity-resolution) run the oidc-authn-plugin, whose +# runtime resolves the JWKS over HTTPS ONLY. This cert-manager Certificate + the +# `authn-tls` sidecar (see deployment.yaml) terminate TLS on :{{ .Values.tlsDiscovery.port }} +# and proxy the well-known endpoints to the authenticator on localhost. `iss` = +# https://{{ $fullname }}:{{ .Values.tlsDiscovery.port }}; downstream trust the +# issuer's CA (ca.crt in the cert Secret) via gateway.caCertPaths. +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ $fullname }}-authn-tls + labels: + {{- include "insight-authenticator.labels" . | nindent 4 }} +spec: + secretName: {{ include "insight-authenticator.authnTlsSecret" . }} + duration: 2160h # 90d + renewBefore: 360h # 15d + privateKey: + algorithm: ECDSA + size: 256 + dnsNames: + - {{ $fullname | quote }} + - {{ printf "%s.%s" $fullname .Release.Namespace | quote }} + - {{ $svc | quote }} + - {{ printf "%s.cluster.local" $svc | quote }} + issuerRef: + name: {{ required "tlsDiscovery.issuerRef.name is required" .Values.tlsDiscovery.issuerRef.name | quote }} + kind: {{ .Values.tlsDiscovery.issuerRef.kind | quote }} + group: cert-manager.io +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $fullname }}-authn-tls + labels: + {{- include "insight-authenticator.labels" . | nindent 4 }} +data: + default.conf: | + server { + listen {{ .Values.tlsDiscovery.port }} ssl; + server_name {{ $fullname }}; + ssl_certificate /tls/tls.crt; + ssl_certificate_key /tls/tls.key; + # Same pod: the authenticator listens on localhost. Only the discovery + # surface is fronted; everything else 404s. + location /.well-known/ { + proxy_pass http://127.0.0.1:{{ .Values.service.port }}; + proxy_set_header Host $host; + } + location = /healthz { return 200 "ok\n"; } + } +{{- end }} diff --git a/src/backend/services/authenticator/helm/templates/deployment.yaml b/src/backend/services/authenticator/helm/templates/deployment.yaml index faa972b1e..1db7ff4ba 100644 --- a/src/backend/services/authenticator/helm/templates/deployment.yaml +++ b/src/backend/services/authenticator/helm/templates/deployment.yaml @@ -77,6 +77,30 @@ spec: {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.tlsDiscovery.enabled }} + # TLS discovery front (NGINX_BFF R1): terminates HTTPS on + # :{{ .Values.tlsDiscovery.port }} and proxies /.well-known/* to the + # authenticator on localhost, so the downstream oidc-authn-plugin can + # resolve the JWKS over https. Cert from cert-manager (see authn-tls.yaml). + - name: authn-tls + image: "{{ .Values.tlsDiscovery.image.repository }}:{{ .Values.tlsDiscovery.image.tag }}" + ports: + - name: tls + containerPort: {{ .Values.tlsDiscovery.port }} + protocol: TCP + volumeMounts: + - name: authn-tls-conf + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + readOnly: true + - name: authn-tls-cert + mountPath: /tls + readOnly: true + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 101 # the nginx-unprivileged user (owns /tmp paths) + {{- end }} volumes: - name: authenticator-config configMap: @@ -84,3 +108,11 @@ spec: - name: signing-keys secret: secretName: {{ required "signingKeysSecret is required (mount the ES256 current.pem/previous.pem)" .Values.signingKeysSecret | quote }} + {{- if .Values.tlsDiscovery.enabled }} + - name: authn-tls-conf + configMap: + name: {{ include "insight-authenticator.fullname" . }}-authn-tls + - name: authn-tls-cert + secret: + secretName: {{ include "insight-authenticator.authnTlsSecret" . }} + {{- end }} diff --git a/src/backend/services/authenticator/helm/templates/service.yaml b/src/backend/services/authenticator/helm/templates/service.yaml index c4cb3ae27..9631dfa1b 100644 --- a/src/backend/services/authenticator/helm/templates/service.yaml +++ b/src/backend/services/authenticator/helm/templates/service.yaml @@ -17,5 +17,14 @@ spec: targetPort: token protocol: TCP name: token + {{- if .Values.tlsDiscovery.enabled }} + # TLS discovery front (NGINX_BFF R1): the authn-tls sidecar terminates HTTPS + # here so downstream verifiers resolve the JWKS over https. The token `iss` + # is `https://:`. + - port: {{ .Values.tlsDiscovery.port }} + targetPort: tls + protocol: TCP + name: tls + {{- end }} selector: {{- include "insight-authenticator.selectorLabels" . | nindent 4 }} diff --git a/src/backend/services/authenticator/helm/values.yaml b/src/backend/services/authenticator/helm/values.yaml index 1ff09730c..38d6620e3 100644 --- a/src/backend/services/authenticator/helm/values.yaml +++ b/src/backend/services/authenticator/helm/values.yaml @@ -63,6 +63,30 @@ existingSecret: "" signingKeysSecret: "" signingKeysPath: /app/keys +# TLS discovery front (NGINX_BFF R1). Downstream verifiers (analytics / +# identity-resolution) run the oidc-authn-plugin, which resolves the JWKS via +# OIDC discovery over HTTPS ONLY. When enabled, an `authn-tls` nginx sidecar +# terminates TLS on `port` (cert issued by cert-manager from `issuerRef`) and +# proxies the well-known endpoints to the authenticator on localhost. The token +# `iss` (gateway_issuer) is then `https://:`, and downstream +# services trust the issuer's CA (the cert Secret's ca.crt) via +# gateway.caCertPaths. Disabled by default; the umbrella turns it on. +tlsDiscovery: + enabled: false + port: 8443 + # cert-manager issuer that signs the discovery cert (local: the self-signed + # `local-ca` ClusterIssuer; prod: a real issuer). + issuerRef: + name: "" + kind: ClusterIssuer + # Secret cert-manager writes the leaf + ca.crt into. Empty = -authn-tls-cert. + certSecret: "" + # Unprivileged nginx (runs non-root, binds the high :8443, writable /tmp paths) + # — the pod's securityContext forbids root. + image: + repository: nginxinc/nginx-unprivileged + tag: "1.27-alpine" + # Health probes. Boot fails closed if Redis is unreachable or the signing keys # are missing (the gear pings Redis and loads keys in `init`), so a Ready pod # has both. (A dedicated /ready that re-checks Redis at runtime is a follow-up.) diff --git a/src/backend/services/authenticator/src/api/handlers.rs b/src/backend/services/authenticator/src/api/handlers.rs index 0874ad5f7..38171cba7 100644 --- a/src/backend/services/authenticator/src/api/handlers.rs +++ b/src/backend/services/authenticator/src/api/handlers.rs @@ -218,8 +218,10 @@ async fn mint_and_store_session( let exp = (now + cfg.jwt_ttl_seconds).min(absolute_expires_at); let claims = GatewayClaims { sub: resolution.person_id.clone(), - tenants: resolution.tenants.clone(), + // Single tenant (multi-tenant-in-token dropped): the person's tenant. + tenant_id: resolution.tenants.first().cloned().unwrap_or_default(), roles: roles.clone(), + sub_type: "user".to_owned(), sid: session_id.clone(), iss: cfg.gateway_issuer.clone(), aud: cfg.jwt_audience.clone(), @@ -241,6 +243,7 @@ async fn mint_and_store_session( let record = SessionRecord { person_id: resolution.person_id.clone(), + email: idp.identity.email.clone(), tenants: resolution.tenants.clone(), roles, idp_iss: idp.issuer.clone(), @@ -330,8 +333,9 @@ async fn reissue_jwt( let exp = (now + state.cfg.jwt_ttl_seconds).min(record.absolute_expires_at); let claims = GatewayClaims { sub: record.person_id.clone(), - tenants: record.tenants.clone(), + tenant_id: record.tenants.first().cloned().unwrap_or_default(), roles: record.roles.clone(), + sub_type: "user".to_owned(), sid: session_id.to_owned(), iss: state.cfg.gateway_issuer.clone(), aud: state.cfg.jwt_audience.clone(), @@ -356,6 +360,36 @@ async fn reissue_jwt( } } +// ── /.well-known/openid-configuration ───────────────────────────────────── + +/// Serve a minimal OIDC discovery document so downstream verifiers +/// (`cf-gears-oidc-authn-plugin`) can resolve the JWKS from the issuer. The +/// plugin fetches `{issuer}/.well-known/openid-configuration` and reads +/// `jwks_uri` — it does not accept a directly-configured JWKS URL. Both fields +/// are derived from `gateway_issuer` (the JWT `iss`), so the advertised issuer +/// matches the token and the JWKS is served from the same origin. +pub async fn openid_configuration(Extension(state): Extension>) -> Response { + let issuer = state.cfg.gateway_issuer.trim_end_matches('/'); + let body = serde_json::json!({ + "issuer": issuer, + "jwks_uri": format!("{issuer}/.well-known/jwks.json"), + // Advertised for OIDC-discovery completeness; downstream verifiers only + // consume `issuer` + `jwks_uri`. Signing is ES256 (gateway JWT). + "id_token_signing_alg_values_supported": ["ES256"], + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + }) + .to_string(); + build_response( + StatusCode::OK, + vec![ + (CONTENT_TYPE.clone(), "application/json".to_owned()), + (CACHE_CONTROL.clone(), "public, max-age=3600".to_owned()), + ], + Body::from(body), + ) +} + // ── /.well-known/jwks.json ──────────────────────────────────────────────── /// Serve the public JWKS (current + previous keys), cacheable for an hour. @@ -399,6 +433,7 @@ pub async fn me(Extension(state): Extension>, jar: CookieJar) -> R let body = serde_json::json!({ "user": record.person_id, + "email": record.email, "tenants": record.tenants, "roles": record.roles, "expires_at": record.expires_at, diff --git a/src/backend/services/authenticator/src/api/mod.rs b/src/backend/services/authenticator/src/api/mod.rs index 72e141a5e..7e6a9b3fc 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -109,8 +109,23 @@ fn register_internal_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Ro .register(router, openapi) } -/// The public JWKS at `/.well-known/jwks.json` (downstream JWT verification). +/// The public well-known surface for downstream JWT verification: the OIDC +/// discovery document (`cf-gears-oidc-authn-plugin` resolves the JWKS from it) +/// and the JWKS itself. fn register_well_known_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::get("/.well-known/openid-configuration") + .operation_id("authenticator.openid_configuration") + .summary("OIDC discovery document (issuer + jwks_uri) for downstream verifiers") + .tag("internal") + .public() + .text_response( + StatusCode::OK, + "OIDC discovery document", + "application/json", + ) + .handler(handlers::openid_configuration) + .register(router, openapi); + OperationBuilder::get("/.well-known/jwks.json") .operation_id("authenticator.jwks") .summary("Public JWKS for gateway-JWT verification") diff --git a/src/backend/services/authenticator/src/gear.rs b/src/backend/services/authenticator/src/gear.rs index a84e028dc..2d8b7eb15 100644 --- a/src/backend/services/authenticator/src/gear.rs +++ b/src/backend/services/authenticator/src/gear.rs @@ -69,8 +69,14 @@ impl Gear for AuthenticatorGear { &cfg.idp.client_id, &cfg.idp.client_secret, )?; - let resolver: Arc = - Arc::new(IdentityPersonResolver::new(&cfg.identity_url)); + // The resolver authenticates its internal Identity lookup with a service + // JWT it mints via the same keystore (fail-closed Identity, R1). + let resolver: Arc = Arc::new(IdentityPersonResolver::new( + &cfg.identity_url, + keystore.clone(), + cfg.gateway_issuer.clone(), + cfg.jwt_audience.clone(), + )); // Parse the service-token registry (DD-AUTH-05). Fails closed at boot // on a malformed public key rather than on the first token request. diff --git a/src/backend/services/authenticator/src/identity.rs b/src/backend/services/authenticator/src/identity.rs index 79fd82a5f..cec592ec0 100644 --- a/src/backend/services/authenticator/src/identity.rs +++ b/src/backend/services/authenticator/src/identity.rs @@ -1,9 +1,9 @@ //! Person resolution, behind a trait. //! //! The callback resolves the IdP-authenticated principal to an internal -//! `person_id` + tenant memberships (DESIGN §3.4). The Identity Service's -//! `GET /v1/persons/{email}` returns `ResolveProfileCommandModel` -//! (`insight_source_id` = the person id) but **no tenant memberships**, so: +//! `person_id` + tenant memberships (DESIGN §3.4). Identity's internal +//! `GET /internal/persons/by-email/{email}` (service-only) returns +//! `insight_source_id` (the person id) but **no tenant memberships**, so: //! //! - `person_id` comes from `insight_source_id` when Identity knows the email; //! - `tenants` are sourced from the validated id_token claim (fakeidp supplies @@ -16,10 +16,15 @@ //! Sitting behind [`PersonResolver`] lets a richer Identity contract (or the //! permissions service) swap the impl without touching the callback. +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + use anyhow::Context as _; use async_trait::async_trait; use uuid::Uuid; +use crate::jwt::{GatewayClaims, KeyStore}; + /// The IdP-authenticated principal, distilled from the validated id_token. #[derive(Debug, Clone)] pub struct IdpIdentity { @@ -48,13 +53,25 @@ pub trait PersonResolver: Send + Sync { } /// `PersonResolver` backed by the Identity Service. +/// +/// Identity is fail-closed (NGINX_BFF R1), and its user-facing +/// `/v1/persons/{email}` is tenant + caller + visibility gated — unusable for +/// the login bootstrap (email → person, before any tenant/caller exists). So +/// this calls the **internal, service-only** endpoint +/// `GET /internal/persons/by-email/{email}`, authenticating with a short-lived +/// **service gateway JWT** the authenticator mints with its own signing key +/// (`sub_type = service`). Tenant-agnostic: the tenants come from the id_token +/// (see `resolve`), not from Identity. #[derive(Clone)] pub struct IdentityPersonResolver { base_url: String, http: reqwest::Client, + keystore: Arc, + issuer: String, + audience: String, } -/// `GET /v1/persons/{email}` response — only the field we need. +/// The internal resolution response — only the field we need. #[derive(serde::Deserialize)] struct ResolveProfile { insight_source_id: Option, @@ -62,24 +79,56 @@ struct ResolveProfile { impl IdentityPersonResolver { /// `base_url` is the Identity Service root, e.g. `http://identity:8082`. + /// `keystore` / `issuer` / `audience` are used to mint the service JWT that + /// authenticates the internal lookup call. #[must_use] - pub fn new(base_url: &str) -> Self { + pub fn new(base_url: &str, keystore: Arc, issuer: String, audience: String) -> Self { Self { base_url: base_url.trim_end_matches('/').to_owned(), http: reqwest::Client::new(), + keystore, + issuer, + audience, } } - /// Look up the internal person id for an email via Identity. + /// Mint a short-lived service gateway JWT for the internal Identity call. + /// `sub` is the authenticator's stable service UUID; `sub_type = service` + /// is what the internal endpoint gates on. Tenant-agnostic, so `tenant_id` + /// is empty (the endpoint does not read it). + fn mint_service_token(&self) -> anyhow::Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before epoch")? + .as_secs(); + let claims = GatewayClaims { + sub: Uuid::new_v5(&Uuid::NAMESPACE_URL, b"service:authenticator").to_string(), + tenant_id: String::new(), + roles: vec!["service".to_owned()], + sub_type: "service".to_owned(), + sid: "service:authenticator".to_owned(), + iss: self.issuer.clone(), + aud: self.audience.clone(), + iat: now, + exp: now + 60, + jti: Uuid::now_v7().to_string(), + }; + self.keystore.sign(&claims) + } + + /// Look up the internal person id for an email via Identity's internal + /// service-only endpoint. async fn lookup_person_id(&self, email: &str) -> anyhow::Result> { if self.base_url.is_empty() { return Ok(None); } let encoded = urlencoding_min(email); - let url = format!("{}/v1/persons/{encoded}", self.base_url); + let url = format!("{}/internal/persons/by-email/{encoded}", self.base_url); + let token = self.mint_service_token()?; let resp = self .http .get(&url) + .bearer_auth(token) .send() .await .context("Identity request")?; diff --git a/src/backend/services/authenticator/src/jwt.rs b/src/backend/services/authenticator/src/jwt.rs index 6e795e1ed..81e8d67b9 100644 --- a/src/backend/services/authenticator/src/jwt.rs +++ b/src/backend/services/authenticator/src/jwt.rs @@ -1,17 +1,13 @@ //! JWT Issuer, Key Store, and JWKS (§3.8 claim contract, DESIGN 3.10). //! -//! **§9.6 decision — ES256.** The deleted spec mandated EdDSA (DD-BFF-05: small -//! signatures, fast verify), but the downstream verifier (`oidc-authn-plugin`) -//! validates RS256/ES256 today; EdDSA would need a non-trivial plugin change, -//! and downstream verification is a later phase. ES256 (P-256 / ECDSA-SHA256) -//! satisfies DD-BFF-05's rationale — 64-byte signatures, fast verify — with -//! zero downstream friction, so the authenticator mints ES256. Recorded in the -//! DESIGN and the PR. -//! -//! Keys are a plain mounted directory (`signing_keys_path`): `current.pem` -//! (PKCS#8 EC P-256 private key, required) and optional `previous.pem` for the -//! rotation overlap. The `kid` is the RFC 7638 JWK thumbprint — stable, needs -//! no manifest. Rotation = drop a new `current.pem` and re-read (or roll pods). +//! **§9.6 decision — ES256.** The gateway JWT is signed ES256 (ECDSA P-256 / +//! SHA-256): 64-byte signatures + fast verify, and the downstream verifier +//! (`cf-gears-oidc-authn-plugin`, whose JWKS goes through `jsonwebtoken`'s +//! EC-capable `JwkSet`) validates EC keys natively. Keys are a plain mounted +//! directory (`signing_keys_path`): `current.pem` (PKCS#8 EC P-256 private key, +//! required) and optional `previous.pem` for the rotation overlap. The `kid` is +//! the RFC 7638 JWK thumbprint — stable, needs no manifest. Rotation = drop a +//! new `current.pem` and re-read (or roll pods). use std::path::Path; @@ -28,12 +24,20 @@ use sha2::{Digest as _, Sha256}; /// The gateway JWT claim set (§3.8). Serializes to the wire claims verbatim. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GatewayClaims { - /// Internal `person_id` (or `service:` for service tokens, later). + /// Internal `person_id` (a UUID), or a service's UUID for service tokens. pub sub: String, - /// All tenant memberships resolved at login — the only tenant authority. - pub tenants: Vec, - /// Access-control roles (default `["user"]` until the permissions service). + /// The single tenant this token is scoped to (UUID). The only tenant + /// authority — downstream reads it as `subject_tenant_id`. + pub tenant_id: String, + /// Access-control roles (default `["user"]` until the permissions service; + /// service tokens include `"service"`). Serialized on the wire as a single + /// space-delimited string (OAuth `scope` convention): the downstream + /// verifier (`cf-gears-oidc-authn-plugin`) reads `token_scopes` via + /// `as_str().split_whitespace()`, so a JSON array would be dropped. + #[serde(with = "space_delimited")] pub roles: Vec, + /// Subject type — `"user"` for people, `"service"` for service tokens. + pub sub_type: String, /// Stable session id (UUIDv7) — survives cookie rotation. pub sid: String, /// Gateway origin issuer URL. @@ -199,6 +203,22 @@ impl KeyStore { } } +/// Serde adapter: represent `Vec` as one space-delimited string on the +/// wire (the OAuth `scope` shape the downstream verifier's `token_scopes` +/// mapping expects). Empty roles serialize to `""` and split back to `[]`. +mod space_delimited { + use serde::{Deserialize as _, Deserializer, Serializer}; + + pub(super) fn serialize(roles: &[String], ser: S) -> Result { + ser.serialize_str(&roles.join(" ")) + } + + pub(super) fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { + let raw = String::deserialize(de)?; + Ok(raw.split_whitespace().map(str::to_owned).collect()) + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -216,8 +236,9 @@ mod tests { fn sample_claims() -> GatewayClaims { GatewayClaims { sub: "person-123".to_owned(), - tenants: vec!["t-a".to_owned(), "t-b".to_owned()], + tenant_id: "t-a".to_owned(), roles: vec!["user".to_owned(), "admin".to_owned()], + sub_type: "user".to_owned(), sid: "sid-xyz".to_owned(), iss: "http://gateway.local".to_owned(), aud: "internal-services".to_owned(), @@ -239,12 +260,25 @@ mod tests { let claims = store.verify(&jwt, "internal-services").unwrap(); assert_eq!(claims.sub, "person-123"); - assert_eq!(claims.tenants, vec!["t-a", "t-b"]); + assert_eq!(claims.tenant_id, "t-a"); assert_eq!(claims.roles, vec!["user", "admin"]); - assert_eq!(claims.sid, "sid-xyz"); + assert_eq!(claims.sub_type, "user"); assert_eq!(claims.aud, "internal-services"); } + #[test] + fn roles_serialize_as_space_delimited_string() { + // The downstream verifier reads `token_scopes` via + // `as_str().split_whitespace()`, so `roles` must be a STRING on the + // wire, not a JSON array — else scopes are silently dropped. + let json = serde_json::to_value(sample_claims()).unwrap(); + assert_eq!(json["roles"], serde_json::json!("user admin")); + + // And it round-trips back to the Vec model. + let back: GatewayClaims = serde_json::from_value(json).unwrap(); + assert_eq!(back.roles, vec!["user", "admin"]); + } + #[test] fn verify_rejects_wrong_audience() { let store = KeyStore::from_pem_for_test(&gen_pem()).unwrap(); diff --git a/src/backend/services/authenticator/src/service_token.rs b/src/backend/services/authenticator/src/service_token.rs index e60b18f14..c30a0ab9f 100644 --- a/src/backend/services/authenticator/src/service_token.rs +++ b/src/backend/services/authenticator/src/service_token.rs @@ -48,6 +48,8 @@ const ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-be /// The role every service token carries, so downstream can authorize /// machine callers off a single well-known role (§10 G1). const SERVICE_ROLE: &str = "service"; +/// `sub_type` claim value marking a service-principal token. +const SERVICE_SUBJECT_TYPE: &str = "service"; // ── Service registry (parsed at boot) ───────────────────────────────────── @@ -211,10 +213,13 @@ fn unverified_claim(jwt: &str, field: &str) -> Option { value.get(field)?.as_str().map(ToOwned::to_owned) } -/// Build the gateway JWT claims for a service token (§10 G1 / §3.8): -/// `sub = sid = service:`, `roles` from the registry with `"service"` -/// guaranteed, and the requested `tenants` (always present — service tokens are -/// tenant-scoped by design; the handler rejects a request that names none). +/// Build the gateway JWT claims for a service token (§10 G1 / §3.8). +/// +/// `sub` is a stable per-service UUID (RFC 4122 v5 over the service name — a +/// clean UUID, not a `service:` string), `sub_type = "service"` marks it +/// so downstream maps it as a service principal, `roles` come from the registry +/// with `"service"` guaranteed, and `tenant_id` is the requested tenant (service +/// tokens are tenant-scoped — the handler rejects a request that names none). fn build_service_claims( cfg: &AuthenticatorConfig, service: &str, @@ -226,13 +231,17 @@ fn build_service_claims( if !roles.iter().any(|r| r == SERVICE_ROLE) { roles.push(SERVICE_ROLE.to_owned()); } + let subject = Uuid::new_v5( + &Uuid::NAMESPACE_URL, + format!("service:{service}").as_bytes(), + ); GatewayClaims { - sub: format!("service:{service}"), - tenants: tenants.to_vec(), + sub: subject.to_string(), + tenant_id: tenants.first().cloned().unwrap_or_default(), roles, - // sid choice (DESIGN §3.8): service tokens have no session, so `sid` - // carries `service:` — a stable, non-empty value that keeps the - // claim shape fixed and correlates a service's issuance in audit/trace. + sub_type: SERVICE_SUBJECT_TYPE.to_owned(), + // `sid` correlates a service's issuance in audit/trace; service tokens + // have no session, so it carries `service:`. sid: format!("service:{service}"), iss: cfg.gateway_issuer.clone(), aud: cfg.jwt_audience.clone(), @@ -281,6 +290,9 @@ struct TokenRequest { /// Handle a service-token request: validate the grant shape, verify the /// assertion, replay-guard its `jti`, apply the tenant-scope policy, then mint /// and return a normal gateway JWT. +// Linear request handler (parse → verify → replay-guard → tenant policy → mint); +// splitting it would scatter the flow without making it clearer. +#[allow(clippy::too_many_lines)] async fn token_handler( State(state): State>, Form(req): Form, @@ -318,6 +330,15 @@ async fn token_handler( "TENANT_REQUIRED", ); } + // Single-tenant on the wire: a token carries exactly one `tenant_id`. Reject + // a request that names more than one rather than silently taking the first. + if requested_tenants.len() > 1 { + return bad_request( + "tenants", + "a service token must name exactly one tenant", + "MULTIPLE_TENANTS", + ); + } // Verify signature + claims against the registry. let verified = match verify_assertion( @@ -386,7 +407,7 @@ async fn token_handler( service = %verified.service, jti = %verified.jti, roles = ?claims.roles, - tenants = ?claims.tenants, + tenant_id = %claims.tenant_id, "service token issued" ); @@ -649,10 +670,15 @@ mod tests { ..Default::default() }; let claims = build_service_claims(&cfg, "seeder", &[], &[], 1_000); - assert_eq!(claims.sub, "service:seeder"); + // `sub` is a clean per-service UUIDv5 (deterministic), not `service:`. + assert_eq!( + claims.sub, + Uuid::new_v5(&Uuid::NAMESPACE_URL, b"service:seeder").to_string() + ); + assert_eq!(claims.sub_type, "service"); assert_eq!(claims.sid, "service:seeder"); assert!(claims.roles.contains(&"service".to_owned())); - assert!(claims.tenants.is_empty()); + assert_eq!(claims.tenant_id, ""); assert_eq!(claims.exp, 1_000 + cfg.service_tokens.token_ttl_seconds); } @@ -671,7 +697,7 @@ mod tests { 1, "service role must not be duplicated" ); - assert_eq!(claims.tenants, vec!["t-1"]); + assert_eq!(claims.tenant_id, "t-1"); } #[test] diff --git a/src/backend/services/authenticator/src/session.rs b/src/backend/services/authenticator/src/session.rs index 300de20e8..6649126c6 100644 --- a/src/backend/services/authenticator/src/session.rs +++ b/src/backend/services/authenticator/src/session.rs @@ -49,6 +49,9 @@ impl LoginState { #[derive(Debug, Clone)] pub struct SessionRecord { pub person_id: String, + /// The person's email (from the id_token at login) — surfaced to the SPA + /// via `/auth/me` so the (email-keyed) frontend can self-locate. + pub email: String, pub tenants: Vec, pub roles: Vec, pub idp_iss: String, @@ -74,6 +77,7 @@ impl SessionRecord { let json = |v: &[String]| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_owned()); vec![ ("person_id", self.person_id.clone()), + ("email", self.email.clone()), ("tenants", json(&self.tenants)), ("roles", json(&self.roles)), ("idp_iss", self.idp_iss.clone()), @@ -113,6 +117,7 @@ impl SessionRecord { Self { person_id: get("person_id"), + email: get("email"), tenants: parse_vec("tenants"), roles: parse_vec("roles"), idp_iss: get("idp_iss"), diff --git a/src/backend/services/authenticator/tests/e2e_login_loop.rs b/src/backend/services/authenticator/tests/e2e_login_loop.rs index 15752ee99..c3672e6db 100644 --- a/src/backend/services/authenticator/tests/e2e_login_loop.rs +++ b/src/backend/services/authenticator/tests/e2e_login_loop.rs @@ -75,7 +75,7 @@ struct Jwk { #[derive(Deserialize)] struct Claims { sub: String, - tenants: Vec, + tenant_id: String, roles: Vec, sid: String, aud: String, @@ -178,7 +178,7 @@ async fn full_login_exchange_logout_loop() { "default role present" ); assert!(!claims.sid.is_empty(), "stable sid present"); - let _ = &claims.tenants; // present (may be empty in a keyless local run) + let _ = &claims.tenant_id; // present (may be empty in a keyless local run) // 6. /auth/me returns the session summary. let me = http diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index 88e8531be..6f1483729 100644 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -34,6 +34,8 @@ echo "==> dev ES256 signing key" # ec_param_enc:named_curve: LibreSSL (macOS) otherwise emits explicit EC params # the authenticator's p256 loader rejects. KEYS_DIR="$(mktemp -d)" +# ES256 gateway signing key (§9.6). Named-curve P-256 (see the LibreSSL note +# above). The service-token client key below is also EC. openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -pkeyopt ec_param_enc:named_curve -out "$KEYS_DIR/current.pem" echo "==> dev service-token keypair (testclient) — generated, never committed" diff --git a/src/backend/services/fakeidp/Dockerfile b/src/backend/services/fakeidp/Dockerfile index 761880f9d..5b4437310 100644 --- a/src/backend/services/fakeidp/Dockerfile +++ b/src/backend/services/fakeidp/Dockerfile @@ -22,8 +22,6 @@ WORKDIR /build 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 plugins/oidc-authn-plugin/Cargo.toml ./plugins/oidc-authn-plugin/Cargo.toml -COPY services/api-gateway/Cargo.toml ./services/api-gateway/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 @@ -36,8 +34,6 @@ 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 plugins/oidc-authn-plugin/src && echo "" > plugins/oidc-authn-plugin/src/lib.rs && \ - mkdir -p services/api-gateway/src && echo "fn main() {}" > services/api-gateway/src/main.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 && \ diff --git a/src/backend/services/fakeidp/helm/Chart.yaml b/src/backend/services/fakeidp/helm/Chart.yaml new file mode 100644 index 000000000..2aff3b37c --- /dev/null +++ b/src/backend/services/fakeidp/helm/Chart.yaml @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..56ea33506 --- /dev/null +++ b/src/backend/services/fakeidp/helm/templates/_helpers.tpl @@ -0,0 +1,13 @@ +{{- 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 new file mode 100644 index 000000000..6163b9f69 --- /dev/null +++ b/src/backend/services/fakeidp/helm/templates/deployment.yaml @@ -0,0 +1,40 @@ +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: + containers: + - name: fakeidp + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + 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 new file mode 100644 index 000000000..3bc108e7b --- /dev/null +++ b/src/backend/services/fakeidp/helm/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- 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/api-gateway/helm/templates/service.yaml b/src/backend/services/fakeidp/helm/templates/service.yaml similarity index 52% rename from src/backend/services/api-gateway/helm/templates/service.yaml rename to src/backend/services/fakeidp/helm/templates/service.yaml index bc7139462..dc5c773f8 100644 --- a/src/backend/services/api-gateway/helm/templates/service.yaml +++ b/src/backend/services/fakeidp/helm/templates/service.yaml @@ -1,9 +1,9 @@ apiVersion: v1 kind: Service metadata: - name: {{ include "insight-api-gateway.fullname" . }} + name: {{ include "insight-fakeidp.fullname" . }} labels: - {{- include "insight-api-gateway.labels" . | nindent 4 }} + {{- include "insight-fakeidp.labels" . | nindent 4 }} spec: type: {{ .Values.service.type }} ports: @@ -12,4 +12,4 @@ spec: protocol: TCP name: http selector: - {{- include "insight-api-gateway.selectorLabels" . | nindent 4 }} + {{- include "insight-fakeidp.selectorLabels" . | nindent 4 }} diff --git a/src/backend/services/fakeidp/helm/values.yaml b/src/backend/services/fakeidp/helm/values.yaml new file mode 100644 index 000000000..98cab2fd7 --- /dev/null +++ b/src/backend/services/fakeidp/helm/values.yaml @@ -0,0 +1,42 @@ +# 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/api-gateway/helm/templates/ingress.yaml b/src/backend/services/gateway/helm/templates/ingress.yaml similarity index 61% rename from src/backend/services/api-gateway/helm/templates/ingress.yaml rename to src/backend/services/gateway/helm/templates/ingress.yaml index 9b006468f..254cf9e2f 100644 --- a/src/backend/services/api-gateway/helm/templates/ingress.yaml +++ b/src/backend/services/gateway/helm/templates/ingress.yaml @@ -1,10 +1,14 @@ {{- if .Values.ingress.enabled }} +# Cluster ingress -> the nginx gateway. The gateway is the single edge proxy: +# it runs auth_request against the authenticator, injects the gateway JWT, and +# fans out to /api/* upstreams + the SPA. So the ingress sends everything ("/") +# here; path routing happens inside the gateway, not at the ingress. apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: {{ include "insight-api-gateway.fullname" . }} + name: {{ include "insight-gateway.fullname" . }} labels: - {{- include "insight-api-gateway.labels" . | nindent 4 }} + {{- include "insight-gateway.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} @@ -25,11 +29,11 @@ spec: {{- end }} http: paths: - - path: {{ .Values.gateway.prefixPath | default "/api" }} + - path: / pathType: Prefix backend: service: - name: {{ include "insight-api-gateway.fullname" . }} + name: {{ include "insight-gateway.fullname" . }} port: name: http {{- end }} diff --git a/src/backend/services/gateway/helm/values.yaml b/src/backend/services/gateway/helm/values.yaml index dc01288c4..cf1cb01d7 100644 --- a/src/backend/services/gateway/helm/values.yaml +++ b/src/backend/services/gateway/helm/values.yaml @@ -11,6 +11,17 @@ service: type: ClusterIP port: 8080 +# Cluster ingress -> gateway. The gateway is the single edge; the ingress routes +# everything ("/") to it (path routing happens inside the gateway). +ingress: + enabled: true + className: nginx + annotations: {} + host: "" # Leave empty for local (no host-based routing) + tls: + enabled: false + secretName: "" + # Gateway upstreams + route table. Every URL is a Helm template rendered with # `tpl` (configmap.yaml / deployment.yaml), so `{{ .Release.Name }}` resolves to # the real service names and there are no hardcoded hosts. Override any URL with @@ -23,19 +34,29 @@ gateway: # Cookie-to-JWT exchange target (FQDN, for the cosocket) and the SPA upstream. authenticatorUrl: 'http://{{ .Release.Name }}-authenticator.{{ .Release.Namespace }}.svc.{{ .Values.gateway.clusterDomain }}:8083' - frontUrl: 'http://{{ .Release.Name }}-frontend:80' + # FQDN, not the short name: routegen emits a `proxy_pass` through nginx's + # runtime `resolver`, which (unlike the OS resolver) does NOT apply the pod's + # /etc/resolv.conf search domains — a short `insight-frontend` fails with + # "could not be resolved (Host not found)". Same reason authenticatorUrl is a + # FQDN. + frontUrl: 'http://{{ .Release.Name }}-frontend.{{ .Release.Namespace }}.svc.{{ .Values.gateway.clusterDomain }}:80' # Route table -> rendered into the ConfigMap's routes.yaml (via `tpl`) and # compiled by routegen at startup. Upstreams are nginx upstream blocks # (short names resolve via the k8s search domain). defaults: stripRequestHeaders: [X-Real-IP, Forwarded] + # stripPrefix turns `/api/analytics/v1/metrics` into the service's own + # `/v1/metrics` before proxying (the app services host at `/v1/*`, unaware of + # the platform prefix). routes: - - prefix: /api/v1/analytics + - prefix: /api/analytics upstream: 'http://{{ .Release.Name }}-analytics:8081' timeoutMs: 60000 - - prefix: /api/v1/identity + stripPrefix: true + - prefix: /api/identity upstream: 'http://{{ .Release.Name }}-identity:8082' + stripPrefix: true # Trusted ingress-hop CIDRs for X-Forwarded-For (set_real_ip_from). Empty = # no client-IP trust; set to the ingress/pod network where TLS terminates in diff --git a/src/backend/services/gateway/tests/conftest.py b/src/backend/services/gateway/tests/conftest.py index e26cb25b4..28989eea7 100644 --- a/src/backend/services/gateway/tests/conftest.py +++ b/src/backend/services/gateway/tests/conftest.py @@ -103,7 +103,7 @@ def stack(): keys = HERE / "keys" keys.mkdir(exist_ok=True) - def _genpkey(out: str) -> None: + def _genpkey_ec(out: str) -> None: subprocess.run( [ "openssl", @@ -121,15 +121,17 @@ def _genpkey(out: str) -> None: capture_output=True, ) - # ES256 gateway signing key. - _genpkey("current.pem") + # ES256 gateway signing key (§9.6): EC P-256 — the authenticator's p256 + # loader requires an EC key, and downstream verifiers validate ES256. + _genpkey_ec("current.pem") (keys / "current.pem").chmod(0o644) # Service-token registry key: the baked config/insight.yaml carries a dev # `testclient` entry (public_key_paths: [testclient.pub.pem], resolved # against public_key_dir=/keys in the e2e compose), so the authenticator # needs the public half present to build the registry and boot. This e2e - # does not exercise service tokens; it just satisfies that dev entry. - _genpkey("testclient.key.pem") + # does not exercise service tokens; it just satisfies that dev entry. The + # service-token client key stays EC (ES256 RFC 7523 assertion). + _genpkey_ec("testclient.key.pem") subprocess.run( [ "openssl", @@ -170,7 +172,7 @@ def session_sid(): so the cache is populated while the authenticator is still up. """ sid = GatewayClient().login() - GatewayClient().request(f"{GW}/api/v1/analytics/warm", headers={"Cookie": f"__Host-sid={sid}"}) + GatewayClient().request(f"{GW}/api/analytics/warm", headers={"Cookie": f"__Host-sid={sid}"}) return sid @@ -189,7 +191,7 @@ def authenticator_down(session_sid): """Kill the authenticator (session already warmed), restore on teardown.""" _compose("kill", "authenticator") # Wait until the fail-closed state is actually reached before yielding. - _wait_status(f"{GW}/api/v1/analytics/x", "__Host-sid=cold-poll", 503) + _wait_status(f"{GW}/api/analytics/x", "__Host-sid=cold-poll", 503) yield _compose("start", "authenticator") _wait_http(f"{GW}/auth/login", want={302}) diff --git a/src/backend/services/gateway/tests/downstream-verify/README.md b/src/backend/services/gateway/tests/downstream-verify/README.md new file mode 100644 index 000000000..efe9b7d7b --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/README.md @@ -0,0 +1,49 @@ +# Downstream-verification e2e + +Proves the **R1 rule** as code (NGINX_BFF §6 / §D): every downstream service +verifies the gateway JWT itself — mandatory, fail-closed, no production disable +knob. Tenant identity comes only from the signed JWT; `X-Tenant-ID` is a +selector among the JWT's signed `tenants[]` (G2). + +## What it stands up + +The full chain with the **real** downstream services behind the OpenResty +gateway: + +``` +fakeidp ─▶ authenticator ─▶ gateway ─▶ {analytics (Rust), identity (.NET)} + │ ▲ + cookie ─▶ JWT (ES256) │ verifies the JWT via JWKS +``` + +- `authenticator` resolves the login user via the **identity-stub** (a test seam + so login works without seeding real identity). +- `analytics` and `identity` are the real services; each verifies the gateway + JWT against the authenticator's JWKS and then maps the claims (analytics via + the shared `authverify` layer; identity via its JwtBearer + `GatewayTenantContext`). +- `MariaDB` backs analytics (migrations run at boot) and identity. + +## The five scenarios (`test_downstream.py`) + +1. Browser-less login → cookie → `GET /api/analytics/...` → **200**. +2. Same request **directly to analytics' port** without a JWT → **401** + (the R1 proof; identity is checked too). +3. Multi-tenant user (`carol`): correct `X-Tenant-ID` → 200; selector outside + the signed set → 403; missing selector with >1 tenants → 400. +4. Step-06 **service token** → analytics accepts it; `roles: ["service"]`. +5. A request reaching analytics without a valid gateway JWT (models a gateway + route shipped without `auth_request`, or a forged/browser token) → **401**. + +## Running + +Requires `docker`, `openssl`, `pytest`, 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 / authenticator / gateway / fakeidp +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/analytics.e2e.yaml b/src/backend/services/gateway/tests/downstream-verify/analytics.e2e.yaml new file mode 100644 index 000000000..e0fa6ff2c --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/analytics.e2e.yaml @@ -0,0 +1,101 @@ +# Downstream-verify e2e analytics host config (bind-mounted over /app/config/insight.yaml). +# +# Same structure as services/analytics/config/insight.yaml, but with CONCRETE +# oidc-authn-plugin verification values for the e2e network: the gateway JWT's +# issuer is the TLS discovery front (`authn-tls`), and its self-signed CA is +# mounted at /certs/ca.pem. The plugin resolves the JWKS via OIDC discovery over +# https (its runtime is https-only — no http discovery), so the authenticator's +# `/.well-known/*` is fronted by the `authn-tls` nginx sidecar. +# +# DB/ClickHouse/identity/redis leaves come from APP__ env in the compose file. + +server: + home_dir: "/app/data" + +logging: + default: + console_level: info + +gears: + api-gateway: + config: + bind_addr: "0.0.0.0:8081" + enable_docs: true + cors_enabled: true + openapi: + title: "Insight Analytics API" + version: "0.1.0" + description: "Read-only query service over predefined ClickHouse metrics" + auth_disabled: false + + gear-orchestrator: + config: {} + + grpc-hub: + config: + listen_addr: "uds:///tmp/analytics-grpc" + + authn-resolver: + config: + vendor: "hyperspot" + + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + trusted_issuers: + # = token `iss` (authenticator gateway_issuer). discovery_url omitted + # → {issuer}/.well-known/openid-configuration, served by authn-tls. + - issuer: "https://authn-tls:8443" + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + # Trust the self-signed CA that signed authn-tls's server cert. + custom_ca_certificate_paths: ["/certs/ca.pem"] + s2s_oauth: + # Unused by analytics; must name a trusted issuer. Never fetched. + discovery_url: "https://authn-tls:8443" + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + + authz-resolver: + config: + vendor: "hyperspot" + + static-authz-plugin: + config: + vendor: "hyperspot" + priority: 100 + + tenant-resolver: + config: + vendor: "hyperspot" + + single-tenant-tr-plugin: + config: + vendor: "hyperspot" + priority: 20 + + analytics: + config: + bind_addr: "0.0.0.0:8081" + database_url: "" + clickhouse_url: "" + clickhouse_database: "insight" + identity_url: "" + redis_url: "" + metric_catalog: + tenant_default_id: ~ diff --git a/src/backend/services/gateway/tests/downstream-verify/authn-tls.conf b/src/backend/services/gateway/tests/downstream-verify/authn-tls.conf new file mode 100644 index 000000000..45a4a1a03 --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/authn-tls.conf @@ -0,0 +1,26 @@ +# TLS discovery front for the authenticator (downstream-verify e2e only). +# +# The upstream `cf-gears-oidc-authn-plugin` verifies the gateway JWT by +# resolving the authenticator's JWKS via OIDC discovery, and its runtime URL +# policy is https-only (no http discovery/JWKS in a real, non-test build). This +# tiny nginx terminates TLS with a self-signed cert (CN/SAN = authn-tls, trusted +# by analytics via /certs/ca.pem) and proxies the well-known endpoints to the +# authenticator's plain-http listener. `iss` = https://authn-tls:8443, so the +# discovery doc + JWKS are served from this origin. +server { + listen 8443 ssl; + server_name authn-tls; + + ssl_certificate /certs/server.pem; + ssl_certificate_key /certs/server.key; + + # Only the discovery surface needs to be reachable over TLS. + location /.well-known/ { + proxy_pass http://authenticator:8083; + proxy_set_header Host $host; + } + + location = /healthz { + return 200 "ok\n"; + } +} diff --git a/src/backend/services/gateway/tests/downstream-verify/conftest.py b/src/backend/services/gateway/tests/downstream-verify/conftest.py new file mode 100644 index 000000000..c3876e189 --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/conftest.py @@ -0,0 +1,310 @@ +"""Session orchestrator for the downstream-verification e2e. + +Owns the compose stack lifecycle (fakeidp + 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. +""" + +from __future__ import annotations + +import os +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent + +# Exchange-cache window (seconds). Drives the authenticator's +# authz_cache_max_age_seconds via the compose `${AUTHZ_CACHE_MAX_AGE:-3}` +# default, so it must be exported into the compose subprocess environment +# before the stack starts (otherwise the harness never comes up). +AUTHZ_CACHE_MAX_AGE = int(os.environ.setdefault("AUTHZ_CACHE_MAX_AGE", "3")) + +COMPOSE = ["docker", "compose", "-f", str(HERE / "docker-compose.e2e.yml")] +SERVICES = [ + "redis", + "mariadb", + "fakeidp", + "identity-stub", + "authenticator", + "authn-tls", + "analytics", + "identity", + "echo", + "gateway", +] + +GW = "http://localhost:18080" +FAKEIDP = "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) +IDENTITY_DIRECT = "http://localhost:18082" # bypasses the gateway (R1 proof) + +# The service-token assertion audience — must match the authenticator's baked +# service_tokens.audience (config/insight.yaml). +SERVICE_TOKEN_AUDIENCE = "http://localhost:8093/internal/token" + +REWRITES = {"http://gateway:8080": GW, "http://fakeidp:8084": FAKEIDP} + + +def _compose(*args: str, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run([*COMPOSE, *args], check=check, capture_output=True, text=True) + + +class Client: + """Minimal HTTP client: no auto-redirects, case-insensitive headers, an OIDC + login helper, and a form POST for the service-token exchange.""" + + def request(self, url, headers=None, method="GET", data=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) + try: + resp = opener.open(req, timeout=20) + return resp.status, self._lower(resp.headers), resp.read() + except urllib.error.HTTPError as e: + return e.code, self._lower(e.headers), e.read() + + @staticmethod + def _lower(headers): + return {k.lower(): v for k, v in headers.items()} + + @staticmethod + def _rewrite(url): + for internal, external in REWRITES.items(): + url = url.replace(internal, external) + return 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. + """ + _, 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) + 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(";"): + part = part.strip() + if part.startswith("__Host-sid="): + return part[len("__Host-sid=") :] + raise AssertionError(f"no __Host-sid in Set-Cookie: {h.get('set-cookie')!r}") + + +def _wait_http(url, want, timeout_s=120): + deadline = time.monotonic() + timeout_s + last = None + while time.monotonic() < deadline: + try: + last, _, _ = Client().request(url) + if last in want: + return + except OSError: + pass + time.sleep(1) + raise TimeoutError(f"not ready: {url} (last={last})") + + +def _genpkey_ec(path: Path) -> None: + subprocess.run( + [ + "openssl", + "genpkey", + "-algorithm", + "EC", + "-pkeyopt", + "ec_paramgen_curve:P-256", + "-pkeyopt", + "ec_param_enc:named_curve", + "-out", + str(path), + ], + check=True, + capture_output=True, + ) + + +def _gen_tls_certs(certs: Path) -> None: + """Self-signed EC cert (SAN=authn-tls) for the TLS discovery front, plus a + CA copy analytics trusts via custom_ca_certificate_paths. reqwest still does + hostname verification against the SAN even for a custom root, so the SAN + must be `authn-tls` (the in-network name the plugin connects to).""" + _genpkey_ec(certs / "server.key") + cnf = certs / "openssl.cnf" + cnf.write_text( + "[req]\n" + "distinguished_name = dn\n" + "x509_extensions = v3\n" + "prompt = no\n" + "[dn]\n" + "CN = authn-tls\n" + "[v3]\n" + "subjectAltName = DNS:authn-tls\n" + ) + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-key", + str(certs / "server.key"), + "-out", + str(certs / "server.pem"), + "-days", + "2", + "-config", + str(cnf), + ], + check=True, + capture_output=True, + ) + # Trust the self-signed leaf directly as a root (it validates itself). + (certs / "ca.pem").write_bytes((certs / "server.pem").read_bytes()) + for f in ("server.key", "server.pem", "ca.pem"): + (certs / f).chmod(0o644) + + +@pytest.fixture(scope="session", autouse=True) +def stack(): + keys = HERE / "keys" + keys.mkdir(exist_ok=True) + # ES256 gateway signing key (EC P-256): the downstream verifiers — the + # oidc-authn-plugin (analytics) and .NET JwtBearer (identity) — validate the + # ES256 gateway JWT the authenticator signs. + _genpkey_ec(keys / "current.pem") + (keys / "current.pem").chmod(0o644) + # TLS discovery front certs (authn-tls) into ./certs. + certs = HERE / "certs" + certs.mkdir(exist_ok=True) + _gen_tls_certs(certs) + # Service-token client keypair: the baked `testclient` registry entry + # references testclient.pub.pem (resolved against public_key_dir=/keys). It + # stays EC — the RFC 7523 client assertion is ES256. + _genpkey_ec(keys / "testclient.key.pem") + subprocess.run( + [ + "openssl", + "pkey", + "-in", + str(keys / "testclient.key.pem"), + "-pubout", + "-out", + str(keys / "testclient.pub.pem"), + ], + check=True, + capture_output=True, + ) + (keys / "testclient.pub.pem").chmod(0o644) + try: + _compose("up", "-d", "--build", *SERVICES) + _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 + # no-cookie /api/* through the gateway must already be 401 (auth wired). + _wait_http(f"{ANALYTICS_DIRECT}/health", want={200}) + _wait_http(f"{IDENTITY_DIRECT}/healthz", want={200}) + yield + finally: + _compose("logs", "--no-color", check=False) + _compose("down", "-v", "--remove-orphans", check=False) + for leftover in ("current.pem", "testclient.key.pem", "testclient.pub.pem"): + (keys / leftover).unlink(missing_ok=True) + keys.rmdir() + for leftover in ("server.key", "server.pem", "ca.pem", "openssl.cnf"): + (certs / leftover).unlink(missing_ok=True) + certs.rmdir() + + +@pytest.fixture +def client(): + return Client() + + +def _gateway_kid(pub) -> str: + """RFC 7638 EC thumbprint (the authenticator's kid scheme): base64url-nopad + SHA-256 of `{"crv":"P-256","kty":"EC","x":"..","y":".."}`.""" + import base64 + import hashlib + + nums = pub.public_numbers() + + def b64(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + xb = b64(nums.x.to_bytes(32, "big")) + yb = b64(nums.y.to_bytes(32, "big")) + canonical = f'{{"crv":"P-256","kty":"EC","x":"{xb}","y":"{yb}"}}' + return b64(hashlib.sha256(canonical.encode()).digest()) + + +def mint_gateway_jwt(claims: dict) -> str: + """Sign a gateway JWT directly with the e2e gateway key (keys/current.pem), + using ES256 + the authenticator's RFC 7638 kid so the downstream plugin + finds the key in the JWKS. Lets a test forge an otherwise-valid token that + omits a required claim (e.g. `tenant_id`) to prove the plugin rejects it.""" + jwt = pytest.importorskip("jwt") # PyJWT + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + key_pem = (HERE / "keys" / "current.pem").read_bytes() + priv = load_pem_private_key(key_pem, password=None) + kid = _gateway_kid(priv.public_key()) + return jwt.encode(claims, key_pem.decode(), algorithm="ES256", headers={"kid": kid}) + + +def mint_service_token(tenant: str) -> str: + """Run the step-06 RFC 7523 flow: sign a client assertion with the testclient + key, exchange it at the authenticator's token endpoint for a gateway service + token (sub = a per-service UUID, sub_type=service, roles include `service`). + Requires PyJWT.""" + jwt = pytest.importorskip("jwt") # PyJWT; skip scenario 4 if unavailable + key_pem = (HERE / "keys" / "testclient.key.pem").read_text() + now = int(time.time()) + assertion = jwt.encode( + { + "iss": "testclient", + "sub": "testclient", + "aud": SERVICE_TOKEN_AUDIENCE, + "jti": f"e2e-{now}-{time.monotonic_ns()}", + "iat": now, + "exp": now + 50, + }, + key_pem, + algorithm="ES256", + ) + status, _, body = Client().request( + f"{AUTH_TOKEN}/internal/token", + method="POST", + data={ + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + "tenants": tenant, + }, + ) + assert status == 200, f"token exchange failed: {status} {body!r}" + import json + + return json.loads(body)["access_token"] 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 new file mode 100644 index 000000000..8195c4839 --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/docker-compose.e2e.yml @@ -0,0 +1,174 @@ +# 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} +# Both analytics (Rust) and identity (.NET) 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); +# the real analytics + identity are the downstream verification targets. +# +# Everything runs on one compose network so DNS names resolve for both the nginx +# upstreams and the Lua cosocket, and the OIDC redirect flow works. Driven by +# run-e2e.sh; assertions run from the host (published ports below). +name: insight-gw-e2e-downstream-verify + +services: + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 30 + + mariadb: + image: mariadb:11.4 + environment: + MARIADB_ROOT_PASSWORD: root-local + MARIADB_DATABASE: identity + MARIADB_USER: insight + MARIADB_PASSWORD: insight-local + volumes: + # Creates the `identity` + `analytics` databases and the `insight` user. + - ../../../../../../deploy/compose/mariadb-init.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 40 + + fakeidp: + build: + context: ../../../.. + dockerfile: services/fakeidp/Dockerfile + 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" + ports: + - "${FAKEIDP_E2E_PORT:-18084}:8084" + + # Canned person resolver for the authenticator's login step (email -> person + + # tenants). Keeps login working without seeding real identity; the real + # identity service is a downstream verification target, not the login backend. + identity-stub: + image: python:3.12-slim + volumes: + - ../../../authenticator/tests/identity-stub.py:/identity-stub.py:ro + command: ["python", "/identity-stub.py", "0.0.0.0:8092"] + + authenticator: + build: + context: ../../../.. + dockerfile: services/authenticator/Dockerfile + depends_on: + redis: {condition: service_healthy} + fakeidp: {condition: service_started} + identity-stub: {condition: service_started} + ports: + - "${AUTHENTICATOR_E2E_PORT:-18083}:8083" + - "${AUTHENTICATOR_TOKEN_E2E_PORT:-18093}:8093" + environment: + APP__gears__authenticator__config__redis_url: "redis://redis:6379" + APP__gears__authenticator__config__signing_keys_path: "/keys" + APP__gears__authenticator__config__identity_url: "http://identity-stub:8092" + # Token `iss` = the TLS discovery front (authn-tls), so downstream + # verifiers resolve 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: "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__client_id: "insight-authenticator" + # 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}" + APP__gears__authenticator__config__service_tokens__public_key_dir: "/keys" + volumes: + - ./keys:/keys:ro + + # TLS discovery front for the authenticator (see authn-tls.conf). The upstream + # oidc-authn-plugin resolves JWKS via OIDC discovery over https ONLY, so the + # authenticator's `/.well-known/*` is fronted here with a self-signed cert + # (CN/SAN authn-tls, trusted by analytics via /certs/ca.pem). certs generated + # by conftest.py into ./certs. + authn-tls: + image: nginx:1.27-alpine + depends_on: + authenticator: {condition: service_started} + volumes: + - ./authn-tls.conf:/etc/nginx/conf.d/default.conf:ro + - ./certs:/certs:ro + + analytics: + build: + context: ../../../.. + dockerfile: services/analytics/Dockerfile + depends_on: + mariadb: {condition: service_healthy} + authenticator: {condition: service_started} + authn-tls: {condition: service_started} + environment: + APP__gears__analytics__config__database_url: "mysql://insight:insight-local@mariadb:3306/analytics" + APP__gears__analytics__config__redis_url: "redis://redis:6379" + # ClickHouse is a post-readiness dependency; list endpoints never touch it, + # so an unreachable URL is fine for this proof (mirrors the http_live_tests + # dead-ClickHouse setup). + APP__gears__analytics__config__clickhouse_url: "http://clickhouse.invalid:8123" + APP__gears__analytics__config__clickhouse_database: "insight" + APP__gears__analytics__config__identity_url: "http://identity:8082" + # The oidc-authn-plugin verification config is nested/list-shaped, so it + # comes from a bind-mounted host config (concrete issuer + CA), not env. The + # self-signed CA for authn-tls is mounted at /certs. + volumes: + - ./analytics.e2e.yaml:/app/config/insight.yaml:ro + - ./certs:/certs:ro + # Published so a driver can hit analytics DIRECTLY (the R1 "no JWT -> 401" proof). + ports: + - "${ANALYTICS_E2E_PORT:-18081}:8081" + + identity: + build: + context: ../../../.. + dockerfile: services/identity/Dockerfile + depends_on: + mariadb: {condition: service_healthy} + authenticator: {condition: service_started} + environment: + IDENTITY__mariadb__url: "mysql://insight:insight-local@mariadb:3306/identity" + IDENTITY__identity__bind_addr: "0.0.0.0:8082" + IDENTITY__identity__tenant_default_id: "00000000-df51-5b42-9538-d2b56b7ee953" + # `iss` must equal the token's (authn-tls). identity is .NET (not the + # gears plugin) and fetches JWKS from an explicit URL with RequireHttps + # off, so it keeps the plain-http JWKS endpoint directly — no TLS front, + # no CA trust needed here. + IDENTITY__identity__auth_gateway_issuer: "https://authn-tls:8443" + IDENTITY__identity__auth_gateway_jwks_url: "http://authenticator:8083/.well-known/jwks.json" + # Published so a driver can hit identity DIRECTLY (the R1 "no JWT -> 401" proof). + ports: + - "${IDENTITY_E2E_PORT:-18082}:8082" + + # Minimal SPA-shell stand-in for the gateway's `location /` passthrough. + echo: + image: python:3.12-slim + volumes: + - ../echo-stub.py:/echo-stub.py:ro + command: ["python", "/echo-stub.py", "0.0.0.0:9090"] + + gateway: + build: + context: ../../../.. + dockerfile: services/gateway/Dockerfile + depends_on: + authenticator: {condition: service_started} + analytics: {condition: service_started} + identity: {condition: service_started} + echo: {condition: service_started} + environment: + GATEWAY_AUTHENTICATOR_URL: "http://authenticator:8083" + GATEWAY_FRONT_URL: "http://echo:9090" + volumes: + - ./routes.e2e.yaml:/etc/gateway/routes.yaml:ro + ports: + - "${GW_E2E_PORT:-18080}:8080" diff --git a/src/backend/services/gateway/tests/downstream-verify/pytest.ini b/src/backend/services/gateway/tests/downstream-verify/pytest.ini new file mode 100644 index 000000000..a964d3711 --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -ra + --tb=short +log_cli = true +log_cli_level = INFO +log_cli_format = %(asctime)s | %(levelname)-7s | %(message)s +log_cli_date_format = %H:%M:%S diff --git a/src/backend/services/gateway/tests/downstream-verify/routes.e2e.yaml b/src/backend/services/gateway/tests/downstream-verify/routes.e2e.yaml new file mode 100644 index 000000000..88fc67eb5 --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/routes.e2e.yaml @@ -0,0 +1,18 @@ +# Downstream-verify route table: the two API prefixes point at the REAL downstream +# services. strip_prefix turns `/api/analytics/v1/metrics` into `/v1/metrics` +# (analytics is addressed directly and serves `/v1/*`), and likewise for +# identity. The nginx.conf is generated from this by the gateway container at +# startup (compose upstream names, resolver 127.0.0.11). +version: 1 +defaults: + strip_request_headers: + - X-Real-IP + - Forwarded +routes: + - prefix: /api/analytics + upstream: http://analytics:8081 + strip_prefix: true + timeout_ms: 60000 + - prefix: /api/identity + upstream: http://identity:8082 + strip_prefix: true diff --git a/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh b/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh new file mode 100755 index 000000000..763869d4c --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/run-e2e.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Downstream-verification e2e (NGINX_BFF §6 R1 / §D). +# +# Brings up the full chain — fakeidp + authenticator + gateway + the REAL +# analytics (Rust) and identity (.NET) 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`). +set -euo pipefail + +cd "$(dirname "$0")" +exec python3 -m pytest "$@" diff --git a/src/backend/services/gateway/tests/downstream-verify/test_downstream.py b/src/backend/services/gateway/tests/downstream-verify/test_downstream.py new file mode 100644 index 000000000..0df9b8ca1 --- /dev/null +++ b/src/backend/services/gateway/tests/downstream-verify/test_downstream.py @@ -0,0 +1,112 @@ +"""Downstream-verification e2e -- the NGINX_BFF §D scenarios. + +The R1 rule as code: every downstream service verifies the gateway JWT itself, +fail-closed, no disable knob. A request without a valid gateway JWT gets 401 no +matter how it arrived. The gateway JWT carries a single signed `tenant_id` (the +sole tenant authority); a token missing it is rejected. Run: run-e2e.sh (or +`pytest` from this dir). +""" + +from __future__ import annotations + +import base64 +import json +import time +import uuid + +from conftest import ANALYTICS_DIRECT, GW, IDENTITY_DIRECT, mint_gateway_jwt, mint_service_token + +# Analytics is addressed directly under the gateway prefix; strip_prefix turns +# `/api/analytics/v1/metrics` into the service's own `/v1/metrics`. +ANALYTICS_VIA_GW = f"{GW}/api/analytics/v1/metrics" +ANALYTICS_LIST_DIRECT = f"{ANALYTICS_DIRECT}/v1/metrics" +IDENTITY_LOOKUP_DIRECT = f"{IDENTITY_DIRECT}/v1/persons/nobody@example.com" + +TENANT_DEV = "00000000-df51-5b42-9538-d2b56b7ee953" # the dev user's tenant +GATEWAY_ISSUER = "https://authn-tls:8443" # = authenticator gateway_issuer + + +def _claims(bearer_jwt: str) -> dict: + payload = bearer_jwt.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + +# ── Scenario 1: login -> cookie -> GET /api/analytics/... -> 200 ────────────── + + +def test_login_then_analytics_returns_200(client): + sid = client.login() + status, _, _ = client.request(ANALYTICS_VIA_GW, headers={"Cookie": f"__Host-sid={sid}"}) + assert status == 200, f"authenticated analytics call got {status}" + + +# ── Scenario 2: direct to the service port without a JWT -> 401 (R1 proof) ──── + + +def test_analytics_direct_without_jwt_is_401(client): + status, _, _ = client.request(ANALYTICS_LIST_DIRECT) + assert status == 401, f"analytics direct/no-JWT got {status}" + + +def test_identity_direct_without_jwt_is_401(client): + # The other downstream service verifies too — fail-closed with no JWT. + status, _, _ = client.request(IDENTITY_LOOKUP_DIRECT) + assert status == 401, f"identity direct/no-JWT got {status}" + + +# ── Scenario 3: a validly-signed token missing `tenant_id` -> 401 ───────────── + + +def test_analytics_valid_signature_missing_tenant_is_401(client): + # Forge a token signed by the real gateway key (correct kid) but WITHOUT a + # `tenant_id` claim: the signature/iss/aud all check out, yet the plugin + # requires `subject_tenant_id` and rejects it — "no tenant, no auth", no Nil + # fallback. + now = int(time.time()) + token = mint_gateway_jwt( + { + "sub": str(uuid.uuid4()), + "roles": "analyst", # space-delimited (OAuth scope) shape + "sub_type": "user", + "iss": GATEWAY_ISSUER, + "aud": "internal-services", + "iat": now, + "exp": now + 300, + "jti": str(uuid.uuid4()), + } + ) + status, _, _ = client.request(ANALYTICS_LIST_DIRECT, headers={"Authorization": f"Bearer {token}"}) + assert status == 401, f"token missing tenant_id got {status}, expected 401" + + +# ── Scenario 4: service token -> accepted, carries the service subject ──────── + + +def test_service_token_accepted_with_service_role(client): + token = mint_service_token(TENANT_DEV) + claims = _claims(token) + # Service-to-service calls go direct (the browser gateway would replace the + # Authorization header). The token itself carries the identity analytics sees. + assert claims["sub_type"] == "service", "service token must carry sub_type=service" + assert "service" in claims["roles"].split(), "service token must carry the service role" + assert uuid.UUID(claims["sub"]), "service token sub must be a UUID (not service:)" + assert claims["tenant_id"] == TENANT_DEV, "service token must carry the requested tenant" + status, _, _ = client.request(ANALYTICS_LIST_DIRECT, headers={"Authorization": f"Bearer {token}"}) + assert status == 200, f"analytics rejected the service token: {status}" + + +# ── Scenario 5: a request reaching analytics without a valid gateway JWT -> 401 ─ + + +def test_gateway_misconfig_or_forged_token_is_401(client): + # Models a gateway route accidentally shipped without `auth_request`: the + # request reaches analytics carrying only what the browser sent (a session + # cookie and/or a stray bearer), never a gateway-minted JWT. Analytics + # fail-closes -> 401 (an availability bug, never a breach). Recorded as a CI + # regression: the downstream check is the real security boundary. + status, _, _ = client.request( + ANALYTICS_LIST_DIRECT, + headers={"Cookie": "__Host-sid=whatever", "Authorization": "Bearer forged.not-a.gateway-jwt"}, + ) + assert status == 401, f"forged/browser token reached analytics as {status}, expected 401" diff --git a/src/backend/services/gateway/tests/pytest.ini b/src/backend/services/gateway/tests/pytest.ini index a964d3711..11468d46e 100644 --- a/src/backend/services/gateway/tests/pytest.ini +++ b/src/backend/services/gateway/tests/pytest.ini @@ -2,6 +2,11 @@ python_files = test_*.py python_classes = Test* python_functions = test_* +# downstream-verify/ is a self-contained e2e suite with its own conftest.py + +# compose; it runs standalone (downstream-verify/run-e2e.sh). Don't recurse into +# it from the step-05 suite here — two bare `conftest` modules on sys.path would +# collide on import. +norecursedirs = downstream-verify addopts = -ra --tb=short diff --git a/src/backend/services/gateway/tests/routes.e2e.yaml b/src/backend/services/gateway/tests/routes.e2e.yaml index 385ba47d6..36f1a5685 100644 --- a/src/backend/services/gateway/tests/routes.e2e.yaml +++ b/src/backend/services/gateway/tests/routes.e2e.yaml @@ -8,7 +8,7 @@ defaults: - X-Real-IP - Forwarded routes: - - prefix: /api/v1/analytics + - prefix: /api/analytics upstream: http://echo:9090 - - prefix: /api/v1/identity + - prefix: /api/identity upstream: http://echo:9090 diff --git a/src/backend/services/gateway/tests/test_gateway.py b/src/backend/services/gateway/tests/test_gateway.py index 378498cf3..95ab47d32 100644 --- a/src/backend/services/gateway/tests/test_gateway.py +++ b/src/backend/services/gateway/tests/test_gateway.py @@ -13,7 +13,7 @@ from conftest import AUTHENTICATOR, AUTHZ_CACHE_MAX_AGE, GW -ROUTES = ["/api/v1/analytics", "/api/v1/identity"] +ROUTES = ["/api/analytics", "/api/identity"] UUID_V7 = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") UNAUTHENTICATED_TYPE = "gts://gts.cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~" @@ -36,7 +36,7 @@ def test_no_cookie_401_on_every_route(client): def test_401_body_is_canonical_problem(client): - _, headers, body = client.request(f"{GW}/api/v1/analytics/x") + _, headers, body = client.request(f"{GW}/api/analytics/x") assert "problem+json" in headers.get("content-type", "") prob = json.loads(body) assert prob["type"] == UNAUTHENTICATED_TYPE @@ -50,7 +50,7 @@ def test_401_body_is_canonical_problem(client): def test_login_injects_verifiable_jwt_and_applies_hygiene(client): sid = client.login() resp = client.request( - f"{GW}/api/v1/analytics/data", + f"{GW}/api/analytics/data", headers={ "Cookie": f"__Host-sid={sid}; keep=1", "Authorization": "Bearer FORGED", @@ -79,7 +79,7 @@ def test_correlation_ids_are_unique_per_request(client): sid = client.login() seen = set() for _ in range(3): - _, _, body = client.request(f"{GW}/api/v1/analytics/data", headers={"Cookie": f"__Host-sid={sid}"}) + _, _, body = client.request(f"{GW}/api/analytics/data", headers={"Cookie": f"__Host-sid={sid}"}) seen.add(json.loads(body)["headers"].get("x-correlation-id")) assert len(seen) == 3, seen @@ -109,12 +109,12 @@ def test_internal_and_unmatched_api_are_404(client): class TestAuthenticatorDown: def test_cached_cookie_still_served(self, client, session_sid, authenticator_down): - status, _, _ = client.request(f"{GW}/api/v1/analytics/x", headers={"Cookie": f"__Host-sid={session_sid}"}) + status, _, _ = client.request(f"{GW}/api/analytics/x", headers={"Cookie": f"__Host-sid={session_sid}"}) assert status == 200, f"got {status}" def test_cold_cookie_fails_closed(self, client, authenticator_down): status, headers, body = client.request( - f"{GW}/api/v1/analytics/x", headers={"Cookie": "__Host-sid=cold-never-seen"} + f"{GW}/api/analytics/x", headers={"Cookie": "__Host-sid=cold-never-seen"} ) assert status == 503, f"got {status}" assert "retry-after" in headers @@ -133,7 +133,7 @@ def test_revocation_within_cache_window(client, session_sid): deadline = time.monotonic() + AUTHZ_CACHE_MAX_AGE + 5 status = None while time.monotonic() < deadline: - status, _, _ = client.request(f"{GW}/api/v1/analytics/x", headers={"Cookie": f"__Host-sid={session_sid}"}) + status, _, _ = client.request(f"{GW}/api/analytics/x", headers={"Cookie": f"__Host-sid={session_sid}"}) if status == 401: break time.sleep(1) @@ -145,7 +145,7 @@ def test_revocation_within_cache_window(client, session_sid): def test_dead_upstream_5xx(client, echo_down): sid = client.login() # exchange succeeds (authenticator up); echo is down - status, _, _ = client.request(f"{GW}/api/v1/analytics/x", headers={"Cookie": f"__Host-sid={sid}"}) + status, _, _ = client.request(f"{GW}/api/analytics/x", headers={"Cookie": f"__Host-sid={sid}"}) # Connection refused -> 502; unroutable (killed container) -> 504 at the # bounded proxy_connect_timeout. Either is the fail-fast dead-upstream path. assert status in (502, 504), f"got {status}" diff --git a/src/backend/services/identity-resolution/Cargo.toml b/src/backend/services/identity-resolution/Cargo.toml index 8bdbc1639..6f8732c18 100644 --- a/src/backend/services/identity-resolution/Cargo.toml +++ b/src/backend/services/identity-resolution/Cargo.toml @@ -14,14 +14,16 @@ workspace = true [dependencies] # ToolKit host runtime. This service runs as a gears-rust host: routes are # served by the `api-gateway` system gear (the REST host) under -# `toolkit::bootstrap::run_server`. Same no-auth system-gear set as `analytics` -# — the platform api-gateway is the sole authenticator and proxies to us. +# `toolkit::bootstrap::run_server`. Auth is ENABLED (NGINX_BFF R1): the +# `oidc-authn-plugin` verifies the ES256 gateway JWT and maps its claims into +# the request SecurityContext — same set as the `analytics` host. toolkit = { workspace = true, features = ["bootstrap"] } -# System gears linked for the REST host + (disabled) auth pipeline. Discovered -# at link time via inventory; mirrors the analytics host's no-auth set. +# System gears linked for the REST host + auth pipeline. Discovered at link time +# via inventory; mirrors the analytics host's authenticated set. api_gateway = { workspace = true } authn-resolver = { workspace = true } +oidc-authn-plugin = { workspace = true } authz-resolver = { workspace = true } tenant-resolver = { workspace = true } single-tenant-tr-plugin = { workspace = true } diff --git a/src/backend/services/identity-resolution/config/insight.yaml b/src/backend/services/identity-resolution/config/insight.yaml index 007d017b9..1eda31a79 100644 --- a/src/backend/services/identity-resolution/config/insight.yaml +++ b/src/backend/services/identity-resolution/config/insight.yaml @@ -2,10 +2,17 @@ # # Usage: identity-resolution --config config/insight.yaml # -# The `api-gateway` system gear is the REST host with auth DISABLED — the -# platform api-gateway is the sole authenticator and proxies to us. Same -# no-auth system-gear set as the analytics host. NO domain gear yet -# (iteration-1 scaffold): the host boots and serves /health only. +# Auth is ENABLED on the api-gateway host (NGINX_BFF R1): the upstream +# `cf-gears-oidc-authn-plugin` verifies the ES256 gateway JWT against the +# authenticator's JWKS (resolved via OIDC discovery on the issuer) and maps its +# claims into the SecurityContext (sub -> subject_id, tenant_id -> +# subject_tenant_id, sub_type -> subject_type, roles -> token_scopes). Same +# authenticated system-gear set as the analytics host. A request without a valid +# gateway JWT carrying a `tenant_id` gets 401 — no auth_disabled path. +# +# The plugin's verification config (jwt.trusted_issuers, expected_audience, +# http_client.custom_ca_certificate_paths) is nested/list-shaped and supplied by +# the deployment's config layer (compose bind-mount / Helm), not APP__ env. server: home_dir: "~/.insight" @@ -23,8 +30,10 @@ gears: openapi: title: "Insight Identity Resolution API" version: "0.1.0" - description: "Identity Resolution read API (Rust port) — auth disabled" - auth_disabled: true + description: "Identity Resolution read API (Rust port)" + # Auth ENABLED: the oidc-authn-plugin verifies the ES256 gateway JWT and + # maps its claims into the SecurityContext (R1). No auth_disabled path. + auth_disabled: false gear-orchestrator: config: {} @@ -37,6 +46,42 @@ gears: config: vendor: "hyperspot" + # Downstream gateway-JWT verification (NGINX_BFF R1). Verifies the ES256 + # gateway JWT against the authenticator's JWKS (resolved via OIDC discovery on + # the issuer) and maps its claims into the SecurityContext. The deployment + # supplies the real issuer + self-signed CA (dev/e2e) via the config layer; + # the placeholders below (`.invalid`, never resolvable) fail closed. + oidc-authn-plugin: + config: + vendor: "hyperspot" + priority: 50 + jwt: + supported_algorithms: ["ES256"] + clock_skew_leeway: 60s + require_audience: true + expected_audience: + - "internal-services" + trusted_issuers: + - issuer: "https://gateway.invalid" + claim_mapping: + subject_id: "sub" + subject_tenant_id: "tenant_id" + subject_type: "sub_type" + token_scopes: "roles" + required_claims: [] + http_client: + request_timeout: 5s + # Dev/e2e self-signed CA injected per deploy: + # custom_ca_certificate_paths: ["/certs/ca.pem"] + # Client-credentials exchange is unused here but required config; + # discovery_url must name a trusted issuer, never fetched. + s2s_oauth: + discovery_url: "https://gateway.invalid" + default_subject_type: "service" + token_cache: + ttl: 300s + max_entries: 100 + authz-resolver: config: vendor: "hyperspot" diff --git a/src/backend/services/identity-resolution/src/api/mod.rs b/src/backend/services/identity-resolution/src/api/mod.rs index 892812d67..b9dbc5680 100644 --- a/src/backend/services/identity-resolution/src/api/mod.rs +++ b/src/backend/services/identity-resolution/src/api/mod.rs @@ -9,11 +9,9 @@ use std::sync::Arc; use axum::Extension; use axum::Router; use axum::http::StatusCode; -use axum::middleware::from_fn; use sea_orm::DatabaseConnection; use toolkit::api::{OpenApiRegistry, OperationBuilder}; -use crate::auth; use crate::config::GearConfig; use crate::domain::profile; @@ -28,17 +26,18 @@ pub struct AppState { /// Mount the identity-resolution routes onto the host's router. /// -/// Builds our endpoints on a fresh sub-router (so the tenant middleware + the -/// `AppState` extension scope to our routes, not the host's `/health`/`/docs`), -/// then merges it into the host router. +/// Builds our endpoints on a fresh sub-router (so the `AppState` extension +/// scopes to our routes, not the host's `/health`/`/docs`), then merges it into +/// the host router. Gateway-JWT identity is enforced entirely by the host authn +/// pipeline: the `oidc-authn-plugin` verifies the ES256 gateway JWT and maps its +/// claims — including the single signed `tenant_id` -> `subject_tenant_id` — into +/// the request `SecurityContext` (`NGINX_BFF` R1). No bespoke tenant layer. pub fn register_routes( host_router: Router, openapi: &dyn OpenApiRegistry, state: Arc, ) -> Router { - let api = build_operations(Router::new(), openapi) - .layer(from_fn(auth::tenant_middleware)) - .layer(Extension(state)); + let api = build_operations(Router::new(), openapi).layer(Extension(state)); host_router.merge(api) } diff --git a/src/backend/services/identity-resolution/src/auth.rs b/src/backend/services/identity-resolution/src/auth.rs deleted file mode 100644 index 8ed62babd..000000000 --- a/src/backend/services/identity-resolution/src/auth.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Tenant-override middleware. -//! -//! The auth-disabled `api-gateway` host injects a single-tenant -//! `toolkit_security::SecurityContext` (`DEFAULT_TENANT_ID`) into every -//! request. This thin, stateless middleware reads it and, when an -//! `X-Insight-Tenant-Id` header carries a non-nil Uuid, rebuilds the context -//! with that tenant so handlers resolve against the right tenant. Mirrors the -//! analytics gear + identity's `HeaderTenantContext`. - -use axum::extract::Request; -use axum::middleware::Next; -use axum::response::Response; -use toolkit_security::SecurityContext; -use uuid::Uuid; - -/// Header carrying the session-bound tenant on internal hops. -pub const TENANT_HEADER: &str = "X-Insight-Tenant-Id"; - -/// Reads the host-injected `SecurityContext` and overrides its tenant from -/// `X-Insight-Tenant-Id` when present, then re-inserts it for handlers. -pub async fn tenant_middleware(mut req: Request, next: Next) -> Response { - let base = req - .extensions() - .get::() - .cloned() - .unwrap_or_else(SecurityContext::anonymous); - - let ctx = match read_session_tenant(&req) { - Some(tenant_id) => rebuild_with_tenant(&base, tenant_id), - None => base, - }; - - req.extensions_mut().insert(ctx); - next.run(req).await -} - -/// Rebuild a `SecurityContext` carrying over subject id/type/scopes but with the -/// overridden `subject_tenant_id`. Falls back to the base context on failure. -fn rebuild_with_tenant(base: &SecurityContext, tenant_id: Uuid) -> SecurityContext { - let mut builder = SecurityContext::builder() - .subject_id(base.subject_id()) - .subject_tenant_id(tenant_id) - .token_scopes(base.token_scopes().to_vec()); - if let Some(subject_type) = base.subject_type() { - builder = builder.subject_type(subject_type); - } - builder.build().unwrap_or_else(|_| base.clone()) -} - -/// Parse the tenant from `X-Insight-Tenant-Id`. Rejects multi-valued headers, -/// `Uuid::nil()`, and unparseable values. -fn read_session_tenant(req: &Request) -> Option { - let mut iter = req.headers().get_all(TENANT_HEADER).iter(); - let first = iter.next()?; - if iter.next().is_some() { - return None; - } - let raw = first.to_str().ok()?; - Uuid::parse_str(raw.trim()).ok().filter(|id| !id.is_nil()) -} diff --git a/src/backend/services/identity-resolution/src/main.rs b/src/backend/services/identity-resolution/src/main.rs index d69b71946..196b2f9e5 100644 --- a/src/backend/services/identity-resolution/src/main.rs +++ b/src/backend/services/identity-resolution/src/main.rs @@ -8,7 +8,6 @@ //! `POST /v1/profiles`) land in the next steps. mod api; -mod auth; mod config; mod domain; mod gear; diff --git a/src/backend/services/identity/helm/templates/deployment.yaml b/src/backend/services/identity/helm/templates/deployment.yaml index f031153fa..deb7906a8 100644 --- a/src/backend/services/identity/helm/templates/deployment.yaml +++ b/src/backend/services/identity/helm/templates/deployment.yaml @@ -90,6 +90,20 @@ spec: env: - name: ASPNETCORE_URLS value: "http://0.0.0.0:{{ .Values.service.port }}" + # Gateway-JWT verification (NGINX_BFF R1). May instead be supplied + # via the existingSecret (umbrella path) — then leave gateway.* unset. + # Each env is emitted only when its chart value is non-empty: an + # env entry overrides the same-named envFrom-secret value, so an + # empty chart value must NOT be rendered or it would clobber the + # secret-provided value. + {{- with .Values.gateway.issuer }} + - name: IDENTITY__identity__auth_gateway_issuer + value: {{ . | quote }} + {{- end }} + {{- with .Values.gateway.jwksUrl }} + - name: IDENTITY__identity__auth_gateway_jwks_url + value: {{ . | quote }} + {{- end }} securityContext: allowPrivilegeEscalation: false {{- with .Values.livenessProbe }} diff --git a/src/backend/services/identity/helm/values.yaml b/src/backend/services/identity/helm/values.yaml index 8f2bca261..5b07d0b2b 100644 --- a/src/backend/services/identity/helm/values.yaml +++ b/src/backend/services/identity/helm/values.yaml @@ -20,12 +20,24 @@ resources: memory: 384Mi # Pre-provisioned Secret with the full identity config: -# IDENTITY__mariadb__url, IDENTITY__identity__tenant_default_id (optional). +# IDENTITY__mariadb__url, IDENTITY__identity__tenant_default_id (optional), +# IDENTITY__identity__auth_gateway_issuer, IDENTITY__identity__auth_gateway_jwks_url. # The umbrella populates `insight-identity-config` automatically # when `identity.deploy=true`; for standalone installs the operator # pre-creates an equivalent Secret. existingSecret: "" +# Gateway-JWT verification (NGINX_BFF R1). When set, these render as +# IDENTITY__identity__auth_gateway_* env vars. Leave empty to supply them via +# the existingSecret instead (the umbrella's preferred path). Identity fails +# closed — startup requires both an issuer and a JWKS URL to be present. +gateway: + # `iss` the token carries = the authenticator's gateway_issuer (gateway origin). + issuer: "" + # Network-reachable JWKS endpoint (the gateway's / authenticator's + # `/.well-known/jwks.json`). This is the per-service GATEWAY_JWKS_URL. + jwksUrl: "" + # Wait for MariaDB TCP before main container starts. Closes the pod-restart # race that the umbrella's pre-install Helm hook (mariadb-init-svcdbs-job) # does not cover: hooks only fire on helm install/upgrade, not on docker diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Auth/GatewayTenantContext.cs b/src/backend/services/identity/src/Insight.Identity.Api/Auth/GatewayTenantContext.cs new file mode 100644 index 000000000..260c3480f --- /dev/null +++ b/src/backend/services/identity/src/Insight.Identity.Api/Auth/GatewayTenantContext.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using Microsoft.AspNetCore.Http; + +namespace Insight.Identity.Api.Auth; + +/// +/// Tenant resolver for the gateway JWT (NGINX_BFF §10 G2). The single signed +/// tenant_id claim is the only tenant authority: a token carries +/// exactly one tenant, minted by the authenticator from the session. There is +/// no X-Tenant-ID selector and no multi-tenant tenants[] array — +/// a tenant from the outside world never passes. +/// +/// Returns the parsed tenant UUID, or null when the token carries no +/// (valid) tenant_id claim, so the composite chain falls through to the +/// configured default (the only fall-through — e.g. bootstrap/admin contexts). +/// The gateway JWT's signature/issuer/audience are already verified by the +/// JwtBearer pipeline before this runs. +/// +public sealed class GatewayTenantContext : ITenantContext +{ + /// The single signed tenant authority claim (a tenant UUID). + public const string TenantClaim = "tenant_id"; + + public Guid? Resolve(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var raw = context.User.FindFirst(TenantClaim)?.Value; + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + return Guid.TryParse(raw.Trim(), CultureInfo.InvariantCulture, out var id) && id != Guid.Empty + ? id + : null; + } +} diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderCallerContext.cs b/src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderCallerContext.cs deleted file mode 100644 index 06b520e8f..000000000 --- a/src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderCallerContext.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Insight.Identity.Domain.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Insight.Identity.Api.Auth; - -/// -/// Resolves the caller's person_id. Tries the -/// X-Insight-Person-Id header first; then JWT id claims -/// (oid / sub) via account_person_map; then JWT -/// email claims (email / preferred_username / upn) -/// via persons.value_type='email'. Result is cached on -/// for the request. The DB lookups -/// filter by tenant — endpoints are expected to gate on tenant -/// (400 tenant_unresolved) before reading the caller, so the -/// resolver only runs when a tenant is in scope. -/// -public sealed class HeaderCallerContext : ICallerContext -{ - public const string HeaderName = "X-Insight-Person-Id"; - private const string CacheKey = "__insight_caller_person_id__"; - - private static readonly string[] IdClaims = ["oid", "sub"]; - private static readonly string[] EmailClaims = ["email", "preferred_username", "upn"]; - - private readonly IPersonsReader _reader; - private readonly ITenantContext _tenant; - private readonly ILogger _log; - - public HeaderCallerContext( - IPersonsReader reader, - ITenantContext tenant, - ILogger log) - { - _reader = reader; - _tenant = tenant; - _log = log; - } - - public async Task ResolveAsync(HttpContext context, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(context); - - // Cache the result (including null) so a single request does - // not hit MariaDB more than once. - if (context.Items.TryGetValue(CacheKey, out var cached)) - { - return (Guid?)cached; - } - var resolved = await ResolveInternalAsync(context, cancellationToken).ConfigureAwait(false); - context.Items[CacheKey] = resolved; - return resolved; - } - - private async Task ResolveInternalAsync(HttpContext context, CancellationToken cancellationToken) - { - // 1. Header wins when present. The header path needs no tenant - // — lets a future api-gateway BFF pre-resolve once and skip - // the JWT steps below. - if (context.Request.Headers.TryGetValue(HeaderName, out var raw) - && Guid.TryParse(raw.ToString(), out var headerPersonId) - && headerPersonId != Guid.Empty) - { - return headerPersonId; - } - - // JWT-driven resolution needs the tenant — every read on - // account_person_map / persons is tenant-scoped. - var tenantId = _tenant.Resolve(context); - if (tenantId is null) - { - return null; - } - - // 2. JWT id claims (oid / sub) → account_person_map. - foreach (var claim in IdClaims) - { - var value = context.User.FindFirst(claim)?.Value?.Trim(); - if (string.IsNullOrEmpty(value)) - { - continue; - } - var matched = await _reader - .ResolvePersonIdByAccountIdAsync(tenantId.Value, value, cancellationToken) - .ConfigureAwait(false); - if (matched is not null && matched != Guid.Empty) - { - return matched; - } - } - - // 3. JWT email claims → persons.value_type='email'. - // TODO(#346-follow-up): Count > 1 means two persons in the - // same tenant share an email — corrupted state. We skip - // rather than pick one at random, which surfaces as 401. - // Will need a clearer error path when multi-tenant rolls in. - foreach (var claim in EmailClaims) - { - var value = context.User.FindFirst(claim)?.Value?.Trim(); - if (string.IsNullOrEmpty(value)) - { - continue; - } - var matches = await _reader - .ResolvePersonIdsByEmailAsync(tenantId.Value, value, cancellationToken) - .ConfigureAwait(false); - if (matches.Count == 1 && matches[0] != Guid.Empty) - { - return matches[0]; - } - if (matches.Count > 1) - { -#pragma warning disable CA1848 // structured warning on a rare path; LoggerMessage.Define is overkill - _log.LogWarning( - "Ambiguous JWT caller: claim {Claim}={Value} matches {Count} persons in tenant {Tenant}", - claim, value, matches.Count, tenantId); -#pragma warning restore CA1848 - } - } - - return null; - } -} diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderTenantContext.cs b/src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderTenantContext.cs deleted file mode 100644 index ce7d7cd4c..000000000 --- a/src/backend/services/identity/src/Insight.Identity.Api/Auth/HeaderTenantContext.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Insight.Identity.Api.Auth; - -/// -/// Reads X-Insight-Tenant-Id from the request. Operators send this -/// header from internal callers (api-gateway, dbt-runner) until the JWT -/// flow lands and tenants are bound to identity claims. -/// -public sealed class HeaderTenantContext : ITenantContext -{ - public const string HeaderName = "X-Insight-Tenant-Id"; - - public Guid? Resolve(HttpContext context) - { - ArgumentNullException.ThrowIfNull(context); - // Reject Guid.Empty — same reasoning as HeaderCallerContext: a - // parseable but non-identity value should not pin tenant context. - if (context.Request.Headers.TryGetValue(HeaderName, out var raw) - && Guid.TryParse(raw.ToString(), out var tenantId) - && tenantId != Guid.Empty) - { - return tenantId; - } - return null; - } -} diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Auth/JwksRetriever.cs b/src/backend/services/identity/src/Insight.Identity.Api/Auth/JwksRetriever.cs new file mode 100644 index 000000000..c64d71385 --- /dev/null +++ b/src/backend/services/identity/src/Insight.Identity.Api/Auth/JwksRetriever.cs @@ -0,0 +1,27 @@ +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Tokens; + +namespace Insight.Identity.Api.Auth; + +/// +/// Fetches a bare JWKS document and parses it into a . +/// +/// The authenticator publishes its signing keys at +/// /.well-known/jwks.json but serves no OIDC discovery document, so the +/// standard Authority/metadata path is unavailable. Paired with a +/// ConfigurationManager<JsonWebKeySet> this gives periodic key +/// refresh and auto-refresh on an unknown kid — i.e. key rotation +/// support — with no discovery endpoint. +/// +public sealed class JwksRetriever : IConfigurationRetriever +{ + public async Task GetConfigurationAsync( + string address, + IDocumentRetriever retriever, + CancellationToken cancel) + { + ArgumentNullException.ThrowIfNull(retriever); + var json = await retriever.GetDocumentAsync(address, cancel).ConfigureAwait(false); + return new JsonWebKeySet(json); + } +} diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Auth/JwtTenantContext.cs b/src/backend/services/identity/src/Insight.Identity.Api/Auth/JwtTenantContext.cs deleted file mode 100644 index c68e92dc7..000000000 --- a/src/backend/services/identity/src/Insight.Identity.Api/Auth/JwtTenantContext.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Insight.Identity.Api.Auth; - -/// -/// Tenant resolver that reads the insight_tenant_id claim from -/// the bearer token decoded by the JwtBearer middleware (see -/// Program.cs auth wiring). Returns null when no claim is -/// present so the composite chain can fall through to the next -/// resolver. Validation of the token itself is owned by api-gateway; -/// this service runs parse-only until #346 pins per-env IdP config. -/// -public sealed class JwtTenantContext : ITenantContext -{ - public Guid? Resolve(HttpContext context) - { - ArgumentNullException.ThrowIfNull(context); - var raw = context.User.FindFirst("insight_tenant_id")?.Value; - // Reject Guid.Empty — a parseable but non-identity value should not - // pin tenant context (same rule as HeaderCallerContext / HeaderTenantContext). - return Guid.TryParse(raw, out var tenantId) && tenantId != Guid.Empty - ? tenantId - : null; - } -} diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Auth/SubjectCallerContext.cs b/src/backend/services/identity/src/Insight.Identity.Api/Auth/SubjectCallerContext.cs new file mode 100644 index 000000000..e0fb7e3a4 --- /dev/null +++ b/src/backend/services/identity/src/Insight.Identity.Api/Auth/SubjectCallerContext.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; + +namespace Insight.Identity.Api.Auth; + +/// +/// Resolves the caller's person_id straight from the gateway JWT's +/// signed sub claim (NGINX_BFF step 07). Under the gateway-JWT contract +/// sub is the internal person id, so there is no IdP-account / +/// email lookup and no X-Insight-Person-Id header trust path — those +/// belonged to the raw-customer-IdP world this step removes. +/// +/// Returns null when sub is absent or not a person UUID — e.g. a +/// service token whose sub is service:<name>. Such callers +/// carry no person; endpoints that require an identified person respond 401/403 +/// accordingly (they authorize service work on the service role instead). +/// +public sealed class SubjectCallerContext : ICallerContext +{ + /// sub prefix that marks a service token, not a person. + public const string ServiceSubjectPrefix = "service:"; + + public Task ResolveAsync(HttpContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var sub = context.User.FindFirst("sub")?.Value; + if (string.IsNullOrWhiteSpace(sub) || sub.StartsWith(ServiceSubjectPrefix, StringComparison.Ordinal)) + { + return Task.FromResult(null); + } + + return Task.FromResult( + Guid.TryParse(sub, out var personId) && personId != Guid.Empty ? (Guid?)personId : null); + } +} diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Configuration/AppOptions.cs b/src/backend/services/identity/src/Insight.Identity.Api/Configuration/AppOptions.cs index 63c34b0be..29807caa3 100644 --- a/src/backend/services/identity/src/Insight.Identity.Api/Configuration/AppOptions.cs +++ b/src/backend/services/identity/src/Insight.Identity.Api/Configuration/AppOptions.cs @@ -20,12 +20,30 @@ public sealed class AppOptions public string BindAddr { get; init; } = "0.0.0.0:8082"; /// - /// Default tenant UUID used when no X-Insight-Tenant-Id - /// header arrives and JWT auth is not yet wired. + /// Single-tenant fallback used only when the gateway JWT carries no + /// tenants claim (e.g. cross-tenant service tokens) — never as a + /// way to override the signed set. /// [ConfigurationKeyName("tenant_default_id")] public Guid? TenantDefaultId { get; init; } + /// + /// Expected issuer (iss) of the gateway JWT — the authenticator's + /// gateway origin. Verified fail-closed (NGINX_BFF R1). Env: + /// IDENTITY__auth_gateway_issuer. + /// + [ConfigurationKeyName("auth_gateway_issuer")] + public string AuthGatewayIssuer { get; init; } = ""; + + /// + /// JWKS endpoint the gateway JWT's signing keys are fetched from (the + /// gateway's / authenticator's /.well-known/jwks.json). This is the + /// per-service GATEWAY_JWKS_URL. Env: + /// IDENTITY__auth_gateway_jwks_url. + /// + [ConfigurationKeyName("auth_gateway_jwks_url")] + public string AuthGatewayJwksUrl { get; init; } = ""; + /// /// Kill switch for the recursive org-tree walk on /// /v1/persons and /v1/profiles. diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/EndpointHelpers.cs b/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/EndpointHelpers.cs index 246bd2686..3b4bd6364 100644 --- a/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/EndpointHelpers.cs +++ b/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/EndpointHelpers.cs @@ -32,7 +32,7 @@ public static class EndpointHelpers Type: CallerUnresolvedUrn, Title: "Unauthorized", Status: StatusCodes.Status401Unauthorized, - Detail: $"Caller not identified. Send the {HeaderCallerContext.HeaderName} header."), + Detail: "Caller not identified. The gateway JWT must carry a person subject (sub)."), statusCode: StatusCodes.Status401Unauthorized), AdminCheckResult.NoTenant => Results.Json(new ProblemResponse( Type: TenantUnresolvedUrn, diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/PersonsEndpoints.cs b/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/PersonsEndpoints.cs index f4c8d63dd..78bc06f19 100644 --- a/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/PersonsEndpoints.cs +++ b/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/PersonsEndpoints.cs @@ -42,7 +42,7 @@ public static IEndpointRouteBuilder MapPersonsEndpoints(this IEndpointRouteBuild Type: "urn:insight:error:tenant_unresolved", Title: "Bad Request", Status: StatusCodes.Status400BadRequest, - Detail: $"Tenant not provided. Send the {HeaderTenantContext.HeaderName} header or configure identity.tenant_default_id."), + Detail: "Tenant not resolved. The gateway JWT must carry a valid tenant_id claim."), statusCode: StatusCodes.Status400BadRequest); } @@ -53,7 +53,7 @@ public static IEndpointRouteBuilder MapPersonsEndpoints(this IEndpointRouteBuild Type: "urn:insight:error:caller_unresolved", Title: "Unauthorized", Status: StatusCodes.Status401Unauthorized, - Detail: $"Caller not identified. Send the {HeaderCallerContext.HeaderName} header."), + Detail: "Caller not identified. The gateway JWT must carry a person subject (sub)."), statusCode: StatusCodes.Status401Unauthorized); } @@ -98,7 +98,7 @@ public static IEndpointRouteBuilder MapPersonsEndpoints(this IEndpointRouteBuild Type: "urn:insight:error:tenant_unresolved", Title: "Bad Request", Status: StatusCodes.Status400BadRequest, - Detail: $"Tenant not provided. Send the {HeaderTenantContext.HeaderName} header or configure identity.tenant_default_id."), + Detail: "Tenant not resolved. The gateway JWT must carry a valid tenant_id claim."), statusCode: StatusCodes.Status400BadRequest); } @@ -109,7 +109,7 @@ public static IEndpointRouteBuilder MapPersonsEndpoints(this IEndpointRouteBuild Type: "urn:insight:error:caller_unresolved", Title: "Unauthorized", Status: StatusCodes.Status401Unauthorized, - Detail: $"Caller not identified. Send the {HeaderCallerContext.HeaderName} header."), + Detail: "Caller not identified. The gateway JWT must carry a person subject (sub)."), statusCode: StatusCodes.Status401Unauthorized); } @@ -164,15 +164,54 @@ public static IEndpointRouteBuilder MapPersonsEndpoints(this IEndpointRouteBuild } }); + // Internal, SERVICE-ONLY person resolution for the login bootstrap. The + // authenticator resolves email -> person_id at login, BEFORE any tenant + // or caller exists, so this deliberately bypasses the tenant + + // visibility gates the user-facing /v1/persons endpoint enforces. It is + // still fail-closed: a valid gateway JWT is required (the fallback + // policy), and a non-service caller (sub_type != "service") gets 403. + app.MapGet("/internal/persons/by-email/{email}", async ( + string email, + HttpContext http, + PersonsRepository repo, + CancellationToken cancellationToken) => + { + if (http.User.FindFirst("sub_type")?.Value != "service") + { + return Results.Json(new ProblemResponse( + Type: "urn:insight:error:service_only", + Title: "Forbidden", + Status: StatusCodes.Status403Forbidden, + Detail: "This endpoint is restricted to service principals (sub_type=service)."), + statusCode: StatusCodes.Status403Forbidden); + } + + var personId = await repo.ResolvePersonIdByEmailAnyTenantAsync(email, cancellationToken) + .ConfigureAwait(false); + if (personId is null) + { + return NotFoundByEmail(email); + } + return Results.Ok(new + { + value_type = "email", + value = email, + insight_source_type = "person", + insight_source_id = personId.Value, + }); + }).ExcludeFromDescription(); // internal S2S endpoint — not part of the public OpenAPI contract + + // Health probes are unauthenticated (NGINX_BFF R1 fail-closed applies to + // the data surface; k8s/compose probes must reach these without a JWT). app.MapGet("/health", async (PersonsRepository repo, CancellationToken cancellationToken) => { var ok = await repo.PingAsync(cancellationToken).ConfigureAwait(false); return ok ? Results.Ok(new { status = "healthy" }) : Results.Json(new { status = "unhealthy" }, statusCode: StatusCodes.Status503ServiceUnavailable); - }); + }).AllowAnonymous(); - app.MapGet("/healthz", () => Results.Text("ok", "text/plain")); + app.MapGet("/healthz", () => Results.Text("ok", "text/plain")).AllowAnonymous(); return app; } diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/SubchartEndpoints.cs b/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/SubchartEndpoints.cs index 9386b52f4..5f423a379 100644 --- a/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/SubchartEndpoints.cs +++ b/src/backend/services/identity/src/Insight.Identity.Api/Endpoints/SubchartEndpoints.cs @@ -71,7 +71,7 @@ public static IEndpointRouteBuilder MapSubchartEndpoints(this IEndpointRouteBuil Type: "urn:insight:error:tenant_unresolved", Title: "Bad Request", Status: StatusCodes.Status400BadRequest, - Detail: $"Tenant not provided. Send the {HeaderTenantContext.HeaderName} header or configure identity.tenant_default_id."), + Detail: "Tenant not resolved. The gateway JWT must carry a valid tenant_id claim."), statusCode: StatusCodes.Status400BadRequest); } @@ -82,7 +82,7 @@ public static IEndpointRouteBuilder MapSubchartEndpoints(this IEndpointRouteBuil Type: "urn:insight:error:caller_unresolved", Title: "Unauthorized", Status: StatusCodes.Status401Unauthorized, - Detail: $"Caller not identified. Send the {HeaderCallerContext.HeaderName} header."), + Detail: "Caller not identified. The gateway JWT must carry a person subject (sub)."), statusCode: StatusCodes.Status401Unauthorized); } @@ -129,7 +129,7 @@ public static IEndpointRouteBuilder MapSubchartEndpoints(this IEndpointRouteBuil Type: "urn:insight:error:tenant_unresolved", Title: "Bad Request", Status: StatusCodes.Status400BadRequest, - Detail: $"Tenant not provided. Send the {HeaderTenantContext.HeaderName} header or configure identity.tenant_default_id."), + Detail: "Tenant not resolved. The gateway JWT must carry a valid tenant_id claim."), statusCode: StatusCodes.Status400BadRequest); } @@ -140,7 +140,7 @@ public static IEndpointRouteBuilder MapSubchartEndpoints(this IEndpointRouteBuil Type: "urn:insight:error:caller_unresolved", Title: "Unauthorized", Status: StatusCodes.Status401Unauthorized, - Detail: $"Caller not identified. Send the {HeaderCallerContext.HeaderName} header."), + Detail: "Caller not identified. The gateway JWT must carry a person subject (sub)."), statusCode: StatusCodes.Status401Unauthorized); } diff --git a/src/backend/services/identity/src/Insight.Identity.Api/Program.cs b/src/backend/services/identity/src/Insight.Identity.Api/Program.cs index 5c0450cbc..f8e8f1c10 100644 --- a/src/backend/services/identity/src/Insight.Identity.Api/Program.cs +++ b/src/backend/services/identity/src/Insight.Identity.Api/Program.cs @@ -15,7 +15,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using MySqlConnector; using Serilog; @@ -102,71 +101,87 @@ // assembly for AbstractValidator implementations. builder.Services.AddValidatorsFromAssemblyContaining(); -// Composite tenant resolver: header → JWT → config default. -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +// Composite tenant resolver (NGINX_BFF §10 G2): the single signed gateway-JWT +// `tenant_id` claim first, then the config default (only for callers with no +// signed tenant). The `X-Tenant-ID` selector and the multi-tenant `tenants[]` +// claim are gone — a token carries exactly one tenant, and a tenant from the +// outside world never passes. +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new CompositeTenantContext(new ITenantContext[] { - sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), })); -// JWT bearer authentication — parse-only mode. The api-gateway already -// validates the token upstream (issuer, audience, signature, lifetime) -// before forwarding the request, so this service treats the JWT as a -// context-bearing envelope: the middleware decodes the payload into a -// ClaimsPrincipal that downstream resolvers (JwtTenantContext, and the -// upcoming caller-id resolver tracked under #346) can read. No -// endpoint enforces authentication in this PR — anonymous requests -// still pass through unchanged. +// Gateway-JWT bearer authentication — full, fail-closed validation +// (NGINX_BFF §6 R1). identity verifies the gateway JWT itself: signature +// (ES256, via the authenticator's JWKS), issuer (the gateway origin), audience +// (internal-services), and lifetime. Raw customer-IdP tokens are no longer +// accepted. There is no parse-only / disable path. // -// TODO(#346): switch to full validation once the IdP authority is -// pinned per environment. The block below is the swap-in skeleton — -// every line must flip together. The `SignatureValidator = null` line -// is load-bearing: without it the no-op below keeps short-circuiting -// signature checks even with `ValidateIssuerSigningKey = true`, which -// would also silently accept `alg=none` tokens. -// options.Authority = configuration["identity:auth_authority"]; -// options.Audience = configuration["identity:auth_audience"]; -// options.TokenValidationParameters.ValidateIssuer = true; -// options.TokenValidationParameters.ValidateAudience = true; -// options.TokenValidationParameters.ValidateLifetime = true; -// options.TokenValidationParameters.ValidateIssuerSigningKey = true; -// options.TokenValidationParameters.RequireSignedTokens = true; -// options.TokenValidationParameters.SignatureValidator = null; +// The authenticator publishes JWKS at `/.well-known/jwks.json` but serves no +// OIDC discovery document, so `Authority` metadata discovery cannot be used; +// the signing keys are resolved directly from the configured JWKS URL with a +// caching ConfigurationManager (periodic refresh + auto-refresh on unknown +// kid, which covers key rotation). +var gatewayIssuer = builder.Configuration["identity:auth_gateway_issuer"] ?? ""; +var gatewayJwksUrl = builder.Configuration["identity:auth_gateway_jwks_url"] ?? ""; +if (string.IsNullOrWhiteSpace(gatewayIssuer) || string.IsNullOrWhiteSpace(gatewayJwksUrl)) +{ + throw new InvalidOperationException( + "identity:auth_gateway_issuer and identity:auth_gateway_jwks_url are required " + + "(env IDENTITY__auth_gateway_issuer / IDENTITY__auth_gateway_jwks_url). " + + "The gateway JWT is verified fail-closed — there is no disable knob."); +} + +// In-cluster JWKS is plain HTTP (TLS terminates at the ingress); do not require +// HTTPS on the document fetch. +var jwksConfigManager = new Microsoft.IdentityModel.Protocols.ConfigurationManager( + gatewayJwksUrl, + new JwksRetriever(), + new Microsoft.IdentityModel.Protocols.HttpDocumentRetriever { RequireHttps = false }); + builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.RequireHttpsMetadata = false; - // Keep JWT claim names as-is (`email`, `sub`, `oid`, …) so we - // can read them by their short JWT names. Without this, the - // default JwtBearer pipeline rewrites `email` / `sub` / `name` - // into long ClaimTypes.* URIs (a legacy WS-Federation hand-me- - // down), and `FindFirst("email")` would return null while - // `oid` (not in the rewrite table) would still work — easy to - // miss in review. False keeps every claim under one rule. + // Keep JWT claim names as-is (`sub`, `tenants`, `roles`, …) so resolvers + // read them by their short names rather than the long ClaimTypes.* URIs + // the default pipeline would rewrite them to. options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { - ValidateIssuer = false, - ValidateAudience = false, - ValidateLifetime = false, - ValidateIssuerSigningKey = false, - RequireSignedTokens = false, - // Accept any token shape; do not enforce signature. Returning - // a parsed JsonWebToken short-circuits the default signature - // verifier and lets the claim pipeline run. - SignatureValidator = (token, _) => new JsonWebToken(token), + ValidateIssuer = true, + ValidIssuer = gatewayIssuer, + ValidateAudience = true, + ValidAudience = "internal-services", + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + // Pin the algorithm to the authenticator's ES256 (ECDSA P-256) — + // reject `alg=none` and RSA/HS confusion outright. + ValidAlgorithms = new[] { SecurityAlgorithms.EcdsaSha256 }, + IssuerSigningKeyResolver = (_, _, _, _) => + jwksConfigManager.GetConfigurationAsync(CancellationToken.None) + .GetAwaiter().GetResult().GetSigningKeys(), }; }); -// Caller resolver — header first, then JWT claims (oid/sub via -// account_person_map, then email/preferred_username/upn via persons). -// Scoped because resolution hits MariaDB through IPersonsReader. -builder.Services.AddScoped(); +// Fail-closed by default (NGINX_BFF R1): every endpoint requires a valid +// gateway JWT unless it opts out with AllowAnonymous (health probes, the +// OpenAPI document). A request without a valid gateway JWT gets 401. +builder.Services.AddAuthorization(options => +{ + options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); +}); + +// Caller resolver — the gateway JWT's `sub` IS the internal person_id +// (NGINX_BFF step 07). No DB lookup, no header trust path. +builder.Services.AddScoped(); // Admin-probe — used by CRUD endpoints on /v1/visibility, /v1/roles, // /v1/person-roles to gate by the `admin` role. Scoped to match the @@ -265,6 +280,7 @@ await BootstrapAdminRunner.RunAsync( { var feature = context.Features.Get(); var ex = feature?.Error; + var logger = context.RequestServices.GetRequiredService() .CreateLogger("Insight.Identity.Api.UnhandledException"); // Log the route TEMPLATE, not the raw path (`/v1/persons/`) @@ -304,10 +320,11 @@ await BootstrapAdminRunner.RunAsync( }); }); -// Populate HttpContext.User from a Bearer token when present. No -// UseAuthorization() — endpoints stay anonymous (#346 will add the -// caller-id check on top once visibility lands). +// Verify the gateway JWT, then enforce the fail-closed fallback policy +// (NGINX_BFF R1): every endpoint requires a valid gateway JWT unless it is +// marked AllowAnonymous (health probes, the OpenAPI document). app.UseAuthentication(); +app.UseAuthorization(); app.MapPersonsEndpoints(); app.MapVisibilityEndpoints(); @@ -317,9 +334,8 @@ await BootstrapAdminRunner.RunAsync( app.MapPersonsSeedEndpoints(); // Serve the OpenAPI document at /openapi.json (parity with analytics-api). -// Public — no caller/tenant header required — so docs tooling and the drift -// gate can fetch the contract; identity endpoints are anonymous today anyway. -app.MapOpenApi("/openapi.json"); +// Anonymous so docs tooling and the drift gate can fetch the contract. +app.MapOpenApi("/openapi.json").AllowAnonymous(); await app.RunAsync().ConfigureAwait(false); diff --git a/src/backend/services/identity/src/Insight.Identity.Api/appsettings.yaml b/src/backend/services/identity/src/Insight.Identity.Api/appsettings.yaml index 83ac36b3b..ffb81bdd6 100644 --- a/src/backend/services/identity/src/Insight.Identity.Api/appsettings.yaml +++ b/src/backend/services/identity/src/Insight.Identity.Api/appsettings.yaml @@ -12,6 +12,13 @@ identity: expand_subordinates: true max_subordinate_depth: 16 org_chart_source_type: "bamboohr" + # Gateway-JWT verification (NGINX_BFF R1). Required at startup — a request + # without a valid gateway JWT gets 401; there is no disable knob. Set per + # deployment via IDENTITY__auth_gateway_issuer / IDENTITY__auth_gateway_jwks_url: + # auth_gateway_issuer = the authenticator's gateway_issuer (the token `iss`) + # auth_gateway_jwks_url = network-reachable JWKS endpoint (GATEWAY_JWKS_URL) + auth_gateway_issuer: "" + auth_gateway_jwks_url: "" Serilog: MinimumLevel: diff --git a/src/backend/services/identity/src/Insight.Identity.Domain/Services/IPersonsReader.cs b/src/backend/services/identity/src/Insight.Identity.Domain/Services/IPersonsReader.cs index 14d94ce46..506656168 100644 --- a/src/backend/services/identity/src/Insight.Identity.Domain/Services/IPersonsReader.cs +++ b/src/backend/services/identity/src/Insight.Identity.Domain/Services/IPersonsReader.cs @@ -20,6 +20,18 @@ public interface IPersonsReader string email, CancellationToken cancellationToken); + /// + /// Tenant-AGNOSTIC email → person_id resolution for the login + /// bootstrap: the authenticator's service-only lookup runs before any tenant + /// is known, so the tenant filter is dropped and any matching tenant's + /// latest observation wins. Returns null when no current + /// value_type='email' observation matches. NEVER call this on a + /// user-facing path — it crosses tenant boundaries by design. + /// + Task ResolvePersonIdByEmailAnyTenantAsync( + string email, + CancellationToken cancellationToken); + /// /// Latest-per-source observations for a single person_id within /// the tenant. Empty list when the person has no observations. diff --git a/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/PersonsRepository.cs b/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/PersonsRepository.cs index 484039a34..03d200c76 100644 --- a/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/PersonsRepository.cs +++ b/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/PersonsRepository.cs @@ -31,6 +31,17 @@ public PersonsRepository(MariaDbConnectionFactory factory) return raw is byte[] bytes && bytes.Length == 16 ? new Guid(bytes, bigEndian: true) : null; } + public async Task ResolvePersonIdByEmailAnyTenantAsync( + string email, + CancellationToken cancellationToken) + { + await using var conn = await _factory.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var cmd = new MySqlCommand(Sql.ResolvePersonIdByEmailAnyTenant, conn); + cmd.Parameters.AddWithValue("@email", email); + var raw = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return raw is byte[] bytes && bytes.Length == 16 ? new Guid(bytes, bigEndian: true) : null; + } + public async Task> GetLatestObservationsAsync( Guid tenantId, Guid personId, diff --git a/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/Sql.cs b/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/Sql.cs index 9b4a809af..025baf675 100644 --- a/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/Sql.cs +++ b/src/backend/services/identity/src/Insight.Identity.Infrastructure/MariaDb/Sql.cs @@ -37,6 +37,33 @@ FROM ranked LIMIT 1 """; + /// + /// Tenant-AGNOSTIC email → person_id resolution for the login bootstrap + /// (the authenticator's service-only `/internal/persons/by-email` call): at + /// login the caller's tenant is not yet known, so the tenant filter is + /// dropped. Any matching tenant's latest observation wins. + /// + public const string ResolvePersonIdByEmailAnyTenant = """ + WITH ranked AS ( + SELECT + person_id, + id, + ROW_NUMBER() OVER ( + PARTITION BY insight_tenant_id, insight_source_type, insight_source_id, value_type, value_id + ORDER BY created_at DESC, id DESC + ) AS rn, + created_at + FROM persons + WHERE value_type = 'email' + AND value_id = @email + ) + SELECT person_id + FROM ranked + WHERE rn = 1 + ORDER BY created_at DESC, id DESC + LIMIT 1 + """; + /// /// Latest observation per (insight_source_type, insight_source_id, /// value_type) for a single person within the tenant. diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/JwtCallerResolveTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/JwtCallerResolveTests.cs deleted file mode 100644 index 23c4bdc88..000000000 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/JwtCallerResolveTests.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Text.Json; -using FluentAssertions; -using MySqlConnector; -using Xunit; - -namespace Insight.Identity.Tests.Integration; - -/// -/// End-to-end tests for the JWT fallback in -/// : with no -/// X-Insight-Person-Id header on the request, the resolver tries -/// oid then sub against account_person_map, then -/// email / preferred_username / upn against -/// persons.value_type='email'. The result is cached on the -/// request so multiple lookups inside one handler share one SQL hit. -/// -[Collection(MariaDbCollection.Name)] -public sealed class JwtCallerResolveTests : IAsyncLifetime -{ - // The test tenant the JWT carries via insight_tenant_id claim. - private static readonly Guid TenantId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - - // A canonical person bound in three ways: account_person_map by oid, - // and persons by email. Each test will hit a different claim shape - // and expect the same person to come out. - private static readonly Guid CallerPersonId = Guid.Parse("019e2bfb-2a7b-7bf0-852c-7053a2c7f50d"); - private static readonly Guid SourceId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - - // Distinct second person — used to assert email returns the right - // person when multiple persons share an oid prefix or similar. - private static readonly Guid OtherPersonId = Guid.Parse("33333333-3333-3333-3333-333333333333"); - - private const string OidValue = "22c303cd-1364-4165-8fb8-7b412f0e3bb6"; - private const string SubValue = "ZfBGhYsNQWyvDWPNRGfd-zefLAdrC0x8g_vJe2veeEI"; - private const string EmailValue = "john.doe@example.com"; - - private readonly MariaDbFixture _fixture; - - public JwtCallerResolveTests(MariaDbFixture fixture) => _fixture = fixture; - - public async Task InitializeAsync() - { - await _fixture.ResetAsync().ConfigureAwait(false); - await SeedCallerAsync().ConfigureAwait(false); - // Caller needs visibility on the target to get past the - // /v1/persons gate; a whole-tenant grant on themselves is the - // simplest way to keep the JWT-resolution assertion isolated - // from visibility logic. - await _fixture.SeedWholeTenantVisibilityAsync(TenantId, CallerPersonId).ConfigureAwait(false); - } - - public Task DisposeAsync() => Task.CompletedTask; - - [Fact] - public async Task Resolves_caller_from_jwt_oid_claim_via_account_person_map() - { - // oid is the stable Entra user id and matches account_person_map - // when an Entra/M365 connector is wired. Tried first. - var response = await CallSelfLookupAsync(BuildJwt(("insight_tenant_id", TenantId.ToString("D")), ("oid", OidValue))) - .ConfigureAwait(false); - await AssertResolvedAsync(response).ConfigureAwait(false); - } - - [Fact] - public async Task Resolves_caller_from_jwt_sub_claim_via_account_person_map() - { - var response = await CallSelfLookupAsync(BuildJwt(("insight_tenant_id", TenantId.ToString("D")), ("sub", SubValue))) - .ConfigureAwait(false); - await AssertResolvedAsync(response).ConfigureAwait(false); - } - - [Fact] - public async Task Resolves_caller_from_jwt_email_claim_via_persons() - { - // Falls back through persons.value_type='email'. Real-world case: - // no oid/sub binding present (no ms-entra connector), email is - // the only claim that finds the caller. - var response = await CallSelfLookupAsync(BuildJwt(("insight_tenant_id", TenantId.ToString("D")), ("email", EmailValue))) - .ConfigureAwait(false); - await AssertResolvedAsync(response).ConfigureAwait(false); - } - - [Fact] - public async Task Resolves_caller_from_jwt_preferred_username_when_email_claim_absent() - { - var response = await CallSelfLookupAsync(BuildJwt( - ("insight_tenant_id", TenantId.ToString("D")), - ("preferred_username", EmailValue))).ConfigureAwait(false); - await AssertResolvedAsync(response).ConfigureAwait(false); - } - - [Fact] - public async Task Resolves_caller_from_jwt_upn_when_email_and_preferred_username_absent() - { - // upn is the last email-shaped fallback before the resolver gives up. - var response = await CallSelfLookupAsync(BuildJwt( - ("insight_tenant_id", TenantId.ToString("D")), - ("upn", EmailValue))).ConfigureAwait(false); - await AssertResolvedAsync(response).ConfigureAwait(false); - } - - [Fact] - public async Task Header_caller_overrides_jwt_when_both_present() - { - // Header points at one person, JWT at another. The resolver - // must return the header value and not look at any JWT claims. - using var app = new TestApplicationFactory( - _fixture.ConnectionString, defaultTenantId: null, defaultCallerPersonId: OtherPersonId); - await SeedOtherCallerAsAdminAsync().ConfigureAwait(false); - var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", BuildJwt(("insight_tenant_id", TenantId.ToString("D")), ("email", EmailValue))); - - var response = await client - .GetAsync(new Uri($"/v1/persons/{EmailValue}", UriKind.Relative)) - .ConfigureAwait(false); - - response.IsSuccessStatusCode.Should().BeTrue( - "the header pinned OtherPersonId as the caller; that person has whole-tenant visibility"); - } - - [Fact] - public async Task No_header_no_jwt_returns_401() - { - // Tenant comes from config so this test isolates the missing- - // caller branch from the missing-tenant branch. - using var app = new TestApplicationFactory( - _fixture.ConnectionString, defaultTenantId: TenantId, defaultCallerPersonId: null); - var response = await app.CreateClient() - .GetAsync(new Uri($"/v1/persons/{EmailValue}", UriKind.Relative)) - .ConfigureAwait(false); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - var doc = await response.ReadJsonAsync().ConfigureAwait(false); - doc.GetProperty("type").GetString().Should().Be("urn:insight:error:caller_unresolved"); - } - - [Fact] - public async Task Jwt_claims_present_but_unmapped_returns_401() - { - // Token carries claims, but none of them maps to a known - // person: the oid is not in account_person_map, the email is - // not in persons. - using var app = new TestApplicationFactory( - _fixture.ConnectionString, defaultTenantId: null, defaultCallerPersonId: null); - var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", BuildJwt( - ("insight_tenant_id", TenantId.ToString("D")), - ("oid", "ffffffff-ffff-ffff-ffff-ffffffffffff"), - ("email", "stranger@example.com"))); - - var response = await client - .GetAsync(new Uri($"/v1/persons/{EmailValue}", UriKind.Relative)) - .ConfigureAwait(false); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - // ── helpers ───────────────────────────────────────────────────── - - private async Task CallSelfLookupAsync(string jwt) - { - // No default header, no config tenant — the JWT is the only - // signal. The caller resolves from the token, then we look up - // the caller's own email (the whole-tenant visibility grant - // makes the visibility check pass). - var app = new TestApplicationFactory( - _fixture.ConnectionString, defaultTenantId: null, defaultCallerPersonId: null); - var client = app.CreateClient(); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - return await client - .GetAsync(new Uri($"/v1/persons/{EmailValue}", UriKind.Relative)) - .ConfigureAwait(false); - } - - private static async Task AssertResolvedAsync(HttpResponseMessage response) - { - if (!response.IsSuccessStatusCode) - { - var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new InvalidOperationException($"Expected 200, got {(int)response.StatusCode}. Body: {body}"); - } - var doc = await response.ReadJsonAsync().ConfigureAwait(false); - doc.GetProperty("email").GetString().Should().Be(EmailValue); - } - - private async Task SeedCallerAsync() - { - await using var conn = new MySqlConnection(_fixture.ConnectionString); - await conn.OpenAsync().ConfigureAwait(false); - - // persons: email observation so the email-claim path resolves. - await InsertPersonObservationAsync(conn, CallerPersonId, "email", EmailValue, isValueId: true).ConfigureAwait(false); - - // account_person_map: one binding for oid, one for sub. The - // two source_type values ('ms-entra' for oid, 'ms-entra-sub' - // for sub) only exist so the (tenant, source_type, source_id, - // source_account_id) primary key stays unique — the resolver - // does not filter by source_type. - await InsertAccountPersonMapAsync(conn, "ms-entra", OidValue, CallerPersonId).ConfigureAwait(false); - await InsertAccountPersonMapAsync(conn, "ms-entra-sub", SubValue, CallerPersonId).ConfigureAwait(false); - } - - private async Task SeedOtherCallerAsAdminAsync() - { - await using var conn = new MySqlConnection(_fixture.ConnectionString); - await conn.OpenAsync().ConfigureAwait(false); - // OtherPerson needs whole-tenant visibility so the header- - // wins test gets past the /v1/persons visibility check. - await _fixture.SeedWholeTenantVisibilityAsync(TenantId, OtherPersonId).ConfigureAwait(false); - } - - private static async Task InsertPersonObservationAsync( - MySqlConnection conn, Guid personId, string valueType, string value, bool isValueId) - { - const string sql = """ - INSERT INTO persons - (value_type, insight_source_type, insight_source_id, insight_tenant_id, - value_id, value_full_text, value, - person_id, author_person_id, reason, created_at) - VALUES - (@vt, 'bamboohr', @src, @tenant, - @vid, NULL, @vraw, - @person, @person, '', UTC_TIMESTAMP(6)) - """; - await using var cmd = new MySqlCommand(sql, conn); - cmd.Parameters.AddWithValue("@vt", valueType); - cmd.Parameters.AddWithValue("@src", SourceId.ToByteArray(bigEndian: true)); - cmd.Parameters.AddWithValue("@tenant", TenantId.ToByteArray(bigEndian: true)); - cmd.Parameters.AddWithValue("@vid", isValueId ? value : DBNull.Value); - cmd.Parameters.AddWithValue("@vraw", isValueId ? DBNull.Value : value); - cmd.Parameters.AddWithValue("@person", personId.ToByteArray(bigEndian: true)); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static async Task InsertAccountPersonMapAsync( - MySqlConnection conn, string sourceType, string sourceAccountId, Guid personId) - { - const string sql = """ - INSERT INTO account_person_map - (insight_tenant_id, insight_source_type, insight_source_id, - source_account_id, person_id, - author_person_id, reason, valid_from, valid_to) - VALUES - (@tenant, @stype, @src, - @account, @person, - @person, 'test seed', '2020-01-01 00:00:00', NULL) - """; - await using var cmd = new MySqlCommand(sql, conn); - cmd.Parameters.AddWithValue("@tenant", TenantId.ToByteArray(bigEndian: true)); - cmd.Parameters.AddWithValue("@stype", sourceType); - cmd.Parameters.AddWithValue("@src", SourceId.ToByteArray(bigEndian: true)); - cmd.Parameters.AddWithValue("@account", sourceAccountId); - cmd.Parameters.AddWithValue("@person", personId.ToByteArray(bigEndian: true)); - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - private static string BuildJwt(params (string Name, string Value)[] claims) - { - // Hand-built token. Program.cs runs JwtBearer in parse-only - // mode (signature check is a no-op), so any 3-segment string - // shaped like a JWT with a real JSON payload works. - static string B64Url(string raw) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(raw); - return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - var header = B64Url("{\"alg\":\"HS256\",\"typ\":\"JWT\"}"); - var payloadJson = "{" + string.Join(",", claims.Select(c => $"\"{c.Name}\":\"{c.Value}\"")) + "}"; - var payload = B64Url(payloadJson); - return $"{header}.{payload}.AAAA"; - } -} diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/PersonsEndpointTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/PersonsEndpointTests.cs index 4f0d225fd..7da9d0ebe 100644 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/PersonsEndpointTests.cs +++ b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/PersonsEndpointTests.cs @@ -70,12 +70,12 @@ public async Task Returns_person_with_assembled_attributes() [Fact] public async Task Returns_400_when_no_tenant_resolved() { - // Caller present (via header), no default tenant configured, - // no X-Insight-Tenant-Id header → tenant resolver null → 400. + // Caller present (default bearer carries `sub`), no default tenant + // configured and no `tenant_id` claim on the token → tenant resolver + // null → 400. using var noTenantApp = new TestApplicationFactory( _fixture.ConnectionString, defaultTenantId: null, defaultCallerPersonId: CallerPersonId); var client = noTenantApp.CreateClient(); - client.DefaultRequestHeaders.Remove(HeaderTenantContext.HeaderName); var response = await client.GetAsync(new Uri("/v1/persons/alice@example.com", UriKind.Relative)) .ConfigureAwait(false); @@ -97,20 +97,23 @@ public async Task Returns_401_when_no_caller_resolved() } [Fact] - public async Task Resolves_tenant_from_jwt_insight_tenant_id_claim() + public async Task Resolves_tenant_from_jwt_tenant_id_claim() { // Proves the JwtBearer middleware wires the bearer payload into - // `HttpContext.User` so `JwtTenantContext` can read the claim. - // No X-Insight-Tenant-Id header, no config default — the only - // path to a tenant is the JWT. Caller still comes via header - // and gets seeded as whole-tenant viewer. + // `HttpContext.User` so `GatewayTenantContext` reads the signed single + // `tenant_id` claim — no config default, the only path to a tenant + // is the JWT. Both caller (`sub`) and tenant come from the token. await SeedAliceAsync().ConfigureAwait(false); using var jwtApp = new TestApplicationFactory( _fixture.ConnectionString, defaultTenantId: null, defaultCallerPersonId: CallerPersonId); await _fixture.SeedWholeTenantVisibilityAsync(TenantId, CallerPersonId).ConfigureAwait(false); var client = jwtApp.CreateClient(); client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", BuildUnverifiedJwt(TenantId)); + new System.Net.Http.Headers.AuthenticationHeaderValue( + "Bearer", + TestApplicationFactory.BuildJwt( + ("sub", CallerPersonId.ToString("D")), + ("tenant_id", TenantId.ToString("D")))); var response = await client.GetAsync(new Uri("/v1/persons/alice@example.com", UriKind.Relative)) .ConfigureAwait(false); @@ -123,24 +126,6 @@ public async Task Resolves_tenant_from_jwt_insight_tenant_id_claim() doc.GetProperty("email").GetString().Should().Be("alice@example.com"); } - private static string BuildUnverifiedJwt(Guid tenantId) - { - // Hand-crafted token: parse-only middleware ignores the signature - // segment so we can omit a real signing key. `alg=HS256` keeps - // the header shape conventional; the third segment is a - // non-empty placeholder (`AAAA`) — `JsonWebToken` requires three - // dot-separated segments and tolerates an opaque signature when - // the SignatureValidator short-circuits. - static string B64Url(string raw) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(raw); - return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - var header = B64Url("{\"alg\":\"HS256\",\"typ\":\"JWT\"}"); - var payload = B64Url($"{{\"insight_tenant_id\":\"{tenantId:D}\"}}"); - return $"{header}.{payload}.AAAA"; - } - private async Task SeedAliceAsync() { await using var conn = new MySqlConnection(_fixture.ConnectionString); diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/ProfilesEndpointTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/ProfilesEndpointTests.cs index 69888b171..91b75b569 100644 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/ProfilesEndpointTests.cs +++ b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/ProfilesEndpointTests.cs @@ -225,7 +225,6 @@ public async Task Returns_400_when_no_tenant_resolved() using var noTenantApp = new TestApplicationFactory( _fixture.ConnectionString, defaultTenantId: null, defaultCallerPersonId: CallerPersonId); var client = noTenantApp.CreateClient(); - client.DefaultRequestHeaders.Remove(HeaderTenantContext.HeaderName); var body = new ResolveProfileCommandModel("email", "alice@x.com", null, null); var response = await client.PostJsonAsync(new Uri("/v1/profiles", UriKind.Relative), body) diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/TestApplicationFactory.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/TestApplicationFactory.cs index c0a09b3ca..97952459d 100644 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/TestApplicationFactory.cs +++ b/src/backend/services/identity/tests/Insight.Identity.Tests.Integration/TestApplicationFactory.cs @@ -1,23 +1,27 @@ using Insight.Identity.Api; -using Insight.Identity.Api.Auth; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; namespace Insight.Identity.Tests.Integration; /// -/// Boots the API in-process against the Testcontainers MariaDB. The -/// fixture's connection string is fed in via mariadb:connection_string -/// so the API uses the exact MySqlConnector settings the container -/// negotiated (SslMode, AllowPublicKeyRetrieval, etc.) — -/// going through the URL form would lose those parameters and trip -/// connection timeouts in some Docker configurations. Pass a -/// defaultTenantId to wire the config-default resolver, or -/// null to exercise the missing-tenant 400 branch. Pass a -/// defaultCallerPersonId so every client request carries the -/// X-Insight-Person-Id header — necessary now that -/// /v1/persons + POST /v1/profiles 401 without a caller. +/// Boots the API in-process against the Testcontainers MariaDB. +/// +/// Auth: the service verifies a gateway JWT fail-closed (NGINX_BFF R1). Rather +/// than stand up a JWKS server per test, the factory loosens the JwtBearer +/// validation for the test host (signature/issuer/audience checks off) so a +/// hand-built token with the right claims authenticates — the real ES256/JWKS +/// path is proven by the compose e2e. A defaultCallerPersonId attaches a +/// default bearer whose sub is that person (the gateway JWT's sub +/// IS the internal person_id); the tenant still resolves from +/// tenant_default_id via the config resolver. Pass null callers / +/// tenants to exercise the missing-caller / missing-tenant branches. /// public sealed class TestApplicationFactory : WebApplicationFactory { @@ -36,6 +40,21 @@ public TestApplicationFactory( _defaultTenantId = defaultTenantId; _defaultCallerPersonId = defaultCallerPersonId; _expandSubordinates = expandSubordinates; + + // The fail-closed gateway-JWT wiring requires these two settings, and + // Program.cs reads them BEFORE `builder.Build()` — so a + // ConfigureAppConfiguration source (applied only at Build) is too late, + // and appsettings.yaml declares them empty (overriding host settings). + // The `IDENTITY__`-prefixed env source is added last in Program.cs and + // is read at CreateBuilder time, so it is the one lever that is both + // visible pre-Build and wins over the empty appsettings default. Values + // are dummy `.invalid`; JwtBearer validation is loosened below so they + // are never fetched. + Environment.SetEnvironmentVariable( + "IDENTITY__identity__auth_gateway_issuer", "https://gateway.test.invalid"); + Environment.SetEnvironmentVariable( + "IDENTITY__identity__auth_gateway_jwks_url", + "https://gateway.test.invalid/.well-known/jwks.json"); } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -67,14 +86,60 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) } config.AddInMemoryCollection(dict); }); + + // Test host only: accept a hand-built (unsigned) gateway JWT so tests + // authenticate without a live JWKS. Production keeps the strict ES256 / + // JWKS validation from Program.cs. + builder.ConfigureTestServices(services => + { + services.PostConfigure( + JwtBearerDefaults.AuthenticationScheme, + options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateIssuerSigningKey = false, + RequireSignedTokens = false, + SignatureValidator = (token, _) => new JsonWebToken(token), + }; + }); + }); } protected override void ConfigureClient(System.Net.Http.HttpClient client) { base.ConfigureClient(client); - if (_defaultCallerPersonId is { } caller) + // Always attach a bearer so the request clears the fail-closed + // `RequireAuthenticatedUser` fallback policy (NGINX_BFF R1) and reaches + // the endpoint. A null caller means "authenticated but no resolvable + // caller": a token WITHOUT a `sub`, so the endpoint's caller resolution + // returns the `caller_unresolved` 401 (the true token-less middleware + // 401 is covered by the compose e2e, not in-process here). + var token = _defaultCallerPersonId is { } caller + ? BuildJwt(("sub", caller.ToString("D"))) + : BuildJwt(); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + + /// + /// Build a hand-shaped gateway JWT with the given string claims. The test + /// host does not verify the signature (see ), + /// so a placeholder signature is fine. + /// + public static string BuildJwt(params (string Name, string Value)[] claims) + { + static string B64Url(string raw) { - client.DefaultRequestHeaders.Add(HeaderCallerContext.HeaderName, caller.ToString("D")); + var bytes = System.Text.Encoding.UTF8.GetBytes(raw); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } + var header = B64Url("{\"alg\":\"ES256\",\"typ\":\"JWT\"}"); + var payloadJson = "{" + string.Join(",", claims.Select(c => $"\"{c.Name}\":\"{c.Value}\"")) + "}"; + var payload = B64Url(payloadJson); + return $"{header}.{payload}.AAAA"; } } diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/GatewayTenantContextTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/GatewayTenantContextTests.cs new file mode 100644 index 000000000..e8bf4e072 --- /dev/null +++ b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/GatewayTenantContextTests.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using FluentAssertions; +using Insight.Identity.Api.Auth; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Insight.Identity.Tests.Unit; + +public sealed class GatewayTenantContextTests +{ + private static readonly Guid TenantA = Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + + private static DefaultHttpContext Context(string? tenantId) + { + var claims = tenantId is null + ? Array.Empty() + : [new Claim(GatewayTenantContext.TenantClaim, tenantId)]; + return new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), + }; + } + + [Fact] + public void Signed_tenant_id_resolves_it() + { + var result = new GatewayTenantContext().Resolve(Context(TenantA.ToString())); + result.Should().Be(TenantA); + } + + [Fact] + public void No_tenant_claim_falls_through() + { + // The ONLY fall-through: no signed tenant → null so the config default + // (bootstrap/admin context) can apply. + var result = new GatewayTenantContext().Resolve(Context(tenantId: null)); + result.Should().BeNull(); + } + + [Fact] + public void Blank_tenant_claim_falls_through() + { + new GatewayTenantContext().Resolve(Context(" ")).Should().BeNull(); + } + + [Fact] + public void Malformed_tenant_claim_falls_through() + { + // A non-UUID (or empty-UUID) value never resolves a tenant — it can + // never introduce one, so it degrades to the fall-through, not a grant. + new GatewayTenantContext().Resolve(Context("not-a-uuid")).Should().BeNull(); + new GatewayTenantContext().Resolve(Context(Guid.Empty.ToString())).Should().BeNull(); + } +} diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderCallerContextTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderCallerContextTests.cs deleted file mode 100644 index c545d914b..000000000 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderCallerContextTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -using FluentAssertions; -using Insight.Identity.Api.Auth; -using Insight.Identity.Domain; -using Insight.Identity.Domain.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Insight.Identity.Tests.Unit; - -public sealed class HeaderCallerContextTests -{ - private static readonly Guid CallerId = Guid.Parse("33333333-3333-3333-3333-333333333333"); - - [Fact] - public async Task Returns_parsed_guid_when_header_present() - { - var context = new DefaultHttpContext(); - context.Request.Headers[HeaderCallerContext.HeaderName] = CallerId.ToString(); - - var resolved = await NewSut().ResolveAsync(context, CancellationToken.None); - - resolved.Should().Be(CallerId); - } - - [Fact] - public async Task Returns_null_when_header_missing_and_no_jwt_claims() - { - var context = new DefaultHttpContext(); - - var resolved = await NewSut().ResolveAsync(context, CancellationToken.None); - - resolved.Should().BeNull(); - } - - [Theory] - [InlineData("")] - [InlineData("not-a-guid")] - [InlineData("33333333-3333-3333-3333")] - public async Task Returns_null_when_header_value_is_not_a_guid(string raw) - { - var context = new DefaultHttpContext(); - context.Request.Headers[HeaderCallerContext.HeaderName] = raw; - - var resolved = await NewSut().ResolveAsync(context, CancellationToken.None); - - resolved.Should().BeNull(); - } - - [Fact] - public async Task Rejects_guid_empty() - { - var context = new DefaultHttpContext(); - context.Request.Headers[HeaderCallerContext.HeaderName] = Guid.Empty.ToString(); - - // Guid.Empty is parseable but is not a real identity — accepting it - // would promote `00000000-…` to a valid caller and pollute the - // audit trail. JWT-fallback also returns null with no claims set. - var resolved = await NewSut().ResolveAsync(context, CancellationToken.None); - - resolved.Should().BeNull(); - } - - [Fact] - public async Task Caches_resolved_result_per_request() - { - // The resolver memoises on HttpContext.Items so multiple intra- - // handler probes (visibility check, admin gate, audit) do not - // re-hit MariaDB. Verified by a counting reader: a second call - // on the same HttpContext must not invoke the reader again. - var context = new DefaultHttpContext(); - // No header, no JWT claims — resolution returns null via the - // null-tenant short-circuit. The cache must still memoise the - // null result. - var counting = new CountingReader(); - var sut = new HeaderCallerContext(counting, new NullTenantContext(), NullLogger.Instance); - - var first = await sut.ResolveAsync(context, CancellationToken.None); - var second = await sut.ResolveAsync(context, CancellationToken.None); - - first.Should().BeNull(); - second.Should().BeNull(); - counting.ResolvePersonIdByAccountIdCalls.Should().Be(0, - "no tenant means the JWT lookup path never runs; cache test still proves the cache key is present"); - - // Now exercise the lookup path: set a tenant + an oid claim that - // resolves to a real caller, then call twice — counter must be 1. - var context2 = new DefaultHttpContext(); - var tenant = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - var person = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - var identity = new System.Security.Claims.ClaimsIdentity("test"); - identity.AddClaim(new System.Security.Claims.Claim("oid", "some-oid")); - context2.User = new System.Security.Claims.ClaimsPrincipal(identity); - var counting2 = new CountingReader(returnedPersonId: person); - var sut2 = new HeaderCallerContext(counting2, new FixedTenantContext(tenant), NullLogger.Instance); - - var firstReal = await sut2.ResolveAsync(context2, CancellationToken.None); - var secondReal = await sut2.ResolveAsync(context2, CancellationToken.None); - - firstReal.Should().Be(person); - secondReal.Should().Be(person); - counting2.ResolvePersonIdByAccountIdCalls.Should().Be(1, - "the second call must come from HttpContext.Items, not from MariaDB"); - } - - private static HeaderCallerContext NewSut() - => new(new NullReader(), new NullTenantContext(), NullLogger.Instance); - - private sealed class FixedTenantContext(Guid tenantId) : ITenantContext - { - public Guid? Resolve(HttpContext context) => tenantId; - } - - private sealed class CountingReader(Guid? returnedPersonId = null) : IPersonsReader - { - public int ResolvePersonIdByAccountIdCalls { get; private set; } - - public Task ResolvePersonIdByAccountIdAsync(Guid tenantId, string accountId, CancellationToken cancellationToken) - { - ResolvePersonIdByAccountIdCalls++; - return Task.FromResult(returnedPersonId); - } - public Task ResolvePersonIdByEmailAsync(Guid tenantId, string email, CancellationToken cancellationToken) - => Task.FromResult(null); - public Task> GetLatestObservationsAsync(Guid tenantId, Guid personId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> GetCurrentParentsAsync(Guid tenantId, Guid childPersonId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> GetCurrentChildrenAsync(Guid tenantId, Guid parentPersonId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> ResolvePersonIdsByEmailAsync(Guid tenantId, string email, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> ResolvePersonIdsBySourceIdAsync(Guid tenantId, string sourceType, Guid sourceId, string value, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> GetCurrentSourceIdsAsync(Guid tenantId, Guid personId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - } - - private sealed class NullTenantContext : ITenantContext - { - public Guid? Resolve(HttpContext context) => null; - } - - private sealed class NullReader : IPersonsReader - { - public Task ResolvePersonIdByEmailAsync(Guid tenantId, string email, CancellationToken cancellationToken) - => Task.FromResult(null); - public Task> GetLatestObservationsAsync(Guid tenantId, Guid personId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> GetCurrentParentsAsync(Guid tenantId, Guid childPersonId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> GetCurrentChildrenAsync(Guid tenantId, Guid parentPersonId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> ResolvePersonIdsByEmailAsync(Guid tenantId, string email, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> ResolvePersonIdsBySourceIdAsync(Guid tenantId, string sourceType, Guid sourceId, string value, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task> GetCurrentSourceIdsAsync(Guid tenantId, Guid personId, CancellationToken cancellationToken) - => Task.FromResult>(Array.Empty()); - public Task ResolvePersonIdByAccountIdAsync(Guid tenantId, string accountId, CancellationToken cancellationToken) - => Task.FromResult(null); - } -} diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderTenantContextTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderTenantContextTests.cs deleted file mode 100644 index 2af915fb6..000000000 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/HeaderTenantContextTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using FluentAssertions; -using Insight.Identity.Api.Auth; -using Microsoft.AspNetCore.Http; -using Xunit; - -namespace Insight.Identity.Tests.Unit; - -public sealed class HeaderTenantContextTests -{ - private static readonly Guid TenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - - [Fact] - public void Returns_parsed_guid_when_header_present() - { - var context = new DefaultHttpContext(); - context.Request.Headers[HeaderTenantContext.HeaderName] = TenantId.ToString(); - - var resolved = new HeaderTenantContext().Resolve(context); - - resolved.Should().Be(TenantId); - } - - [Fact] - public void Returns_null_when_header_missing() - { - var context = new DefaultHttpContext(); - - var resolved = new HeaderTenantContext().Resolve(context); - - resolved.Should().BeNull(); - } - - [Theory] - [InlineData("")] - [InlineData("not-a-guid")] - [InlineData("11111111-1111-1111-1111")] - public void Returns_null_when_header_value_is_not_a_guid(string raw) - { - var context = new DefaultHttpContext(); - context.Request.Headers[HeaderTenantContext.HeaderName] = raw; - - var resolved = new HeaderTenantContext().Resolve(context); - - resolved.Should().BeNull(); - } - - [Fact] - public void Rejects_guid_empty() - { - var context = new DefaultHttpContext(); - context.Request.Headers[HeaderTenantContext.HeaderName] = Guid.Empty.ToString(); - - var resolved = new HeaderTenantContext().Resolve(context); - - // Guid.Empty is parseable but is not a real identity — accepting it - // would let a misbehaving gateway pin a phantom tenant context for - // every downstream lookup. - resolved.Should().BeNull(); - } -} diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/JwtTenantContextTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/JwtTenantContextTests.cs deleted file mode 100644 index 8870404f8..000000000 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/JwtTenantContextTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Security.Claims; -using FluentAssertions; -using Insight.Identity.Api.Auth; -using Microsoft.AspNetCore.Http; -using Xunit; - -namespace Insight.Identity.Tests.Unit; - -public sealed class JwtTenantContextTests -{ - private const string ClaimName = "insight_tenant_id"; - private static readonly Guid TenantId = Guid.Parse("22222222-2222-2222-2222-222222222222"); - - private static DefaultHttpContext ContextWithClaim(string? value) - { - var context = new DefaultHttpContext(); - var claims = value is null - ? Array.Empty() - : new[] { new Claim(ClaimName, value) }; - context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); - return context; - } - - [Fact] - public void Returns_parsed_guid_when_claim_present() - { - var resolved = new JwtTenantContext().Resolve(ContextWithClaim(TenantId.ToString())); - - resolved.Should().Be(TenantId); - } - - [Fact] - public void Returns_null_when_claim_missing() - { - var resolved = new JwtTenantContext().Resolve(ContextWithClaim(null)); - - resolved.Should().BeNull(); - } - - [Theory] - [InlineData("")] - [InlineData("not-a-guid")] - [InlineData("22222222-2222-2222-2222")] - public void Returns_null_when_claim_is_not_a_guid(string raw) - { - var resolved = new JwtTenantContext().Resolve(ContextWithClaim(raw)); - - resolved.Should().BeNull(); - } - - [Fact] - public void Rejects_guid_empty() - { - var resolved = new JwtTenantContext().Resolve(ContextWithClaim(Guid.Empty.ToString())); - - // Guid.Empty is parseable but is not a real identity — accepting it - // would let a JWT with a default-valued claim pin a phantom tenant - // context for every downstream lookup. - resolved.Should().BeNull(); - } -} diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/ProfileLookupServiceTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/ProfileLookupServiceTests.cs index 1d0e5051e..90f4c2cf9 100644 --- a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/ProfileLookupServiceTests.cs +++ b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/ProfileLookupServiceTests.cs @@ -115,6 +115,9 @@ public Task> GetCurrentSourceIdsAsync(Guid tenantI public Task ResolvePersonIdByEmailAsync(Guid tenantId, string email, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ResolvePersonIdByEmailAnyTenantAsync(string email, CancellationToken cancellationToken) + => Task.FromResult(null); + public Task> GetCurrentParentsAsync(Guid tenantId, Guid childPersonId, CancellationToken cancellationToken) => Task.FromResult>(Array.Empty()); diff --git a/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/SubjectCallerContextTests.cs b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/SubjectCallerContextTests.cs new file mode 100644 index 000000000..b0c762797 --- /dev/null +++ b/src/backend/services/identity/tests/Insight.Identity.Tests.Unit/SubjectCallerContextTests.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using FluentAssertions; +using Insight.Identity.Api.Auth; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Insight.Identity.Tests.Unit; + +public sealed class SubjectCallerContextTests +{ + private static readonly Guid Person = Guid.Parse("33333333-3333-4333-8333-333333333333"); + + private static DefaultHttpContext WithSub(string? sub) + { + var claims = sub is null ? Array.Empty() : [new Claim("sub", sub)]; + return new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), + }; + } + + [Fact] + public async Task Resolves_person_id_from_sub() + { + var result = await new SubjectCallerContext() + .ResolveAsync(WithSub(Person.ToString("D")), CancellationToken.None); + result.Should().Be(Person); + } + + [Fact] + public async Task Service_subject_has_no_person() + { + var result = await new SubjectCallerContext() + .ResolveAsync(WithSub("service:seeder"), CancellationToken.None); + result.Should().BeNull(); + } + + [Fact] + public async Task Missing_sub_has_no_person() + { + var result = await new SubjectCallerContext() + .ResolveAsync(WithSub(null), CancellationToken.None); + result.Should().BeNull(); + } + + [Fact] + public async Task Non_uuid_sub_has_no_person() + { + var result = await new SubjectCallerContext() + .ResolveAsync(WithSub("not-a-uuid"), CancellationToken.None); + result.Should().BeNull(); + } +} diff --git a/src/backend/tools/routegen/src/emit.rs b/src/backend/tools/routegen/src/emit.rs index c73cf5e41..1582b15ed 100644 --- a/src/backend/tools/routegen/src/emit.rs +++ b/src/backend/tools/routegen/src/emit.rs @@ -137,7 +137,10 @@ pub fn emit(config: &RouteConfig, settings: &Settings) -> anyhow::Result } let (_, auth_authority) = authority_of(&settings.authenticator_url, "authenticator url")?; - let (_, front_authority) = authority_of(&settings.front_url, "front url")?; + // Validate the front URL early; the front location resolves it lazily + // (variable proxy_pass) inside emit_server so a down/absent SPA doesn't stop + // the gateway booting. + let _ = authority_of(&settings.front_url, "front url")?; let authz_url = format!( "{}{}", settings.authenticator_url.trim_end_matches('/'), @@ -176,10 +179,10 @@ pub fn emit(config: &RouteConfig, settings: &Settings) -> anyhow::Result c, " upstream authenticator {{ server {auth_authority}; keepalive 32; }}" )?; - writeln!( - c, - " upstream insight_front {{ server {front_authority}; keepalive 32; }}" - )?; + // NB: no static `upstream insight_front` — the SPA front is resolved lazily + // in `location /` (variable proxy_pass + the http-block resolver) so the + // gateway starts even when the frontend is absent (backend-only / API + auth + // dev against a separately-run SPA). See emit_server. for u in &upstreams { writeln!( c, @@ -345,8 +348,15 @@ fn emit_server( c.push_str( " # the SPA rides through the gateway: one origin, one __Host- cookie (DD-GW-04)\n", ); + // Resolve the SPA upstream LAZILY via a variable + the http-block resolver, + // so the gateway boots even when the frontend is absent (backend-only / API + // + auth dev against a separately-run SPA). `/` then 502s until a front is + // up, but `/api/*` and `/auth/*` work. A static `upstream {}` would instead + // fail nginx config load with "host not found in upstream". + let (front_scheme, front_authority) = authority_of(&settings.front_url, "front url")?; c.push_str(" location / {\n"); - c.push_str(" proxy_pass http://insight_front;\n"); + writeln!(c, " set $insight_front \"{front_authority}\";")?; + writeln!(c, " proxy_pass {front_scheme}://$insight_front;")?; c.push_str(" proxy_set_header Connection \"\";\n"); c.push_str(" proxy_set_header Host $host;\n"); c.push_str(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"); diff --git a/src/backend/tools/routegen/tests/fixtures/full.nginx.conf b/src/backend/tools/routegen/tests/fixtures/full.nginx.conf index c268c1d6a..afd531044 100644 --- a/src/backend/tools/routegen/tests/fixtures/full.nginx.conf +++ b/src/backend/tools/routegen/tests/fixtures/full.nginx.conf @@ -52,7 +52,6 @@ http { # --- upstreams (keepalive-pooled) --- upstream authenticator { server authenticator.insight.svc.cluster.local:8083; keepalive 32; } - upstream insight_front { server insight-front.insight.svc.cluster.local:8080; keepalive 32; } upstream up_analytics_insight_svc_cluster_local_8081 { server analytics.insight.svc.cluster.local:8081; keepalive 32; } upstream up_identity_insight_svc_cluster_local_8082 { server identity.insight.svc.cluster.local:8082; keepalive 32; } @@ -87,8 +86,8 @@ http { } # --- generated /api routes: full auth + hygiene block per location --- - # route: /api/v1/analytics -> http://analytics.insight.svc.cluster.local:8081 - location /api/v1/analytics { + # route: /api/analytics -> http://analytics.insight.svc.cluster.local:8081 + location /api/analytics { access_by_lua_block { require("gateway").exchange() } proxy_pass http://up_analytics_insight_svc_cluster_local_8081; proxy_set_header Host $host; @@ -103,8 +102,8 @@ http { proxy_buffering off; } - # route: /api/v1/identity -> http://identity.insight.svc.cluster.local:8082 - location /api/v1/identity { + # route: /api/identity -> http://identity.insight.svc.cluster.local:8082 + location /api/identity { access_by_lua_block { require("gateway").exchange() } proxy_pass http://up_identity_insight_svc_cluster_local_8082; proxy_set_header Host $host; @@ -148,7 +147,8 @@ http { # the SPA rides through the gateway: one origin, one __Host- cookie (DD-GW-04) location / { - proxy_pass http://insight_front; + set $insight_front "insight-front.insight.svc.cluster.local:8080"; + proxy_pass http://$insight_front; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/src/backend/tools/routegen/tests/fixtures/full.routes.yaml b/src/backend/tools/routegen/tests/fixtures/full.routes.yaml index 1b9b089c3..f1b138d6e 100644 --- a/src/backend/tools/routegen/tests/fixtures/full.routes.yaml +++ b/src/backend/tools/routegen/tests/fixtures/full.routes.yaml @@ -10,12 +10,12 @@ defaults: - X-Real-IP - Forwarded routes: - - prefix: /api/v1/analytics + - prefix: /api/analytics upstream: http://analytics.insight.svc.cluster.local:8081 timeout_ms: 60000 strip_prefix: false - - prefix: /api/v1/identity + - prefix: /api/identity upstream: http://identity.insight.svc.cluster.local:8082 - prefix: /api/v1/stream diff --git a/src/backend/tools/routegen/tests/fixtures/stripprefix.nginx.conf b/src/backend/tools/routegen/tests/fixtures/stripprefix.nginx.conf index 7e4419fe9..fb6aeb84a 100644 --- a/src/backend/tools/routegen/tests/fixtures/stripprefix.nginx.conf +++ b/src/backend/tools/routegen/tests/fixtures/stripprefix.nginx.conf @@ -52,7 +52,6 @@ http { # --- upstreams (keepalive-pooled) --- upstream authenticator { server authenticator.insight.svc.cluster.local:8083; keepalive 32; } - upstream insight_front { server insight-front.insight.svc.cluster.local:8080; keepalive 32; } upstream up_edge_svc_443 { server edge.svc:443; keepalive 32; } # coarse per-IP flood guard for /auth/* (G8); precise limiting is in the authenticator @@ -127,7 +126,8 @@ http { # the SPA rides through the gateway: one origin, one __Host- cookie (DD-GW-04) location / { - proxy_pass http://insight_front; + set $insight_front "insight-front.insight.svc.cluster.local:8080"; + proxy_pass http://$insight_front; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/src/backend/tools/routegen/tests/golden.rs b/src/backend/tools/routegen/tests/golden.rs index 00e41ec67..0e1f507d3 100644 --- a/src/backend/tools/routegen/tests/golden.rs +++ b/src/backend/tools/routegen/tests/golden.rs @@ -63,7 +63,10 @@ fn every_api_location_has_the_hygiene_block() { assert!(conf.contains("location /internal/ {\n return 404;")); // Auth surface is a plain proxy (no exchange) and the SPA rides through. assert!(conf.contains("location /auth/ {")); - assert!(conf.contains("location / {\n proxy_pass http://insight_front;")); + // The SPA front is resolved lazily (variable proxy_pass + resolver) so the + // gateway boots without a frontend present. + assert!(conf.contains("location / {\n set $insight_front \"")); + assert!(conf.contains("proxy_pass http://$insight_front;")); // HSTS on every response (G9). assert!(conf.contains("add_header Strict-Transport-Security")); } diff --git a/src/ingestion/tests/e2e/api/conftest.py b/src/ingestion/tests/e2e/api/conftest.py index 5d9fc09bf..acaf30f15 100644 --- a/src/ingestion/tests/e2e/api/conftest.py +++ b/src/ingestion/tests/e2e/api/conftest.py @@ -12,12 +12,12 @@ import uuid import pytest - -from api.endpoint_helpers import create_scratch_metric from lib import mariadb from lib.analytics import AnalyticsProcess from lib.config import SessionConfig +from api.endpoint_helpers import create_scratch_metric + @pytest.fixture def api(analytics: AnalyticsProcess): @@ -26,6 +26,16 @@ def api(analytics: AnalyticsProcess): yield c +@pytest.fixture +def other_tenant_headers(analytics) -> dict: + """`Authorization` for a DIFFERENT tenant — overrides the client's default + bearer to exercise cross-tenant 403s (the signed `tenant_id` is the tenant + authority now that `X-Insight-Tenant-Id` is gone).""" + from api.endpoint_helpers import OTHER_TENANT + + return {"Authorization": f"Bearer {analytics.bearer(OTHER_TENANT)}"} + + @pytest.fixture def scratch_metric(api) -> dict: """A scratch metric (`e2e-scratch-*`, deterministic system.one query_ref); @@ -70,9 +80,7 @@ def purge_tenant_admin_rows(api, metric_id: str) -> None: Local-rerun hygiene: a persistent MariaDB volume keeps prior rows, and the (metric, tenant, scope) composite is UNIQUE — a fresh create would 409. """ - r = api.get( - "/v1/admin/metric-thresholds", params={"metric_id": metric_id, "scope": "tenant"} - ) + r = api.get("/v1/admin/metric-thresholds", params={"metric_id": metric_id, "scope": "tenant"}) assert r.status_code == 200, f"admin pre-clean: status={r.status_code} body={r.text}" for row in r.json()["items"]: api.delete(f"/v1/admin/metric-thresholds/{row['id']}") @@ -101,8 +109,7 @@ def seeded_columns(session_cfg: SessionConfig) -> dict: ) yield rows mariadb.query( - session_cfg, - f"DELETE FROM table_columns WHERE id IN (UNHEX('{rows['ids'][0]}'), UNHEX('{rows['ids'][1]}'))", + session_cfg, f"DELETE FROM table_columns WHERE id IN (UNHEX('{rows['ids'][0]}'), UNHEX('{rows['ids'][1]}'))" ) diff --git a/src/ingestion/tests/e2e/api/test_admin_thresholds.py b/src/ingestion/tests/e2e/api/test_admin_thresholds.py index d58a27620..b83168a67 100644 --- a/src/ingestion/tests/e2e/api/test_admin_thresholds.py +++ b/src/ingestion/tests/e2e/api/test_admin_thresholds.py @@ -15,10 +15,10 @@ from __future__ import annotations import pytest +from lib.config import TEST_TENANT_ID from api.conftest import purge_tenant_admin_rows -from api.endpoint_helpers import NON_UUID, OTHER_TENANT, UNKNOWN_ID, text_body_request -from lib.config import TENANT_HEADER, TEST_TENANT_ID +from api.endpoint_helpers import NON_UUID, UNKNOWN_ID, text_body_request pytestmark = pytest.mark.api @@ -84,8 +84,7 @@ def test_create_400_unknown_metric(api) -> None: """Referential integrity is checked pre-write: metric_id must resolve to an enabled metric_catalog row.""" r = api.post( - "/v1/admin/metric-thresholds", - json={"metric_id": UNKNOWN_ID, "scope": "tenant", "good": 0.0, "warn": 0.0}, + "/v1/admin/metric-thresholds", json={"metric_id": UNKNOWN_ID, "scope": "tenant", "good": 0.0, "warn": 0.0} ) assert r.status_code == 400, f"status={r.status_code} body={r.text}" @@ -101,12 +100,7 @@ def test_create_409_duplicate(api, admin_threshold_row: dict) -> None: (today it falls through to the internal-500 schema-drift alarm).""" r = api.post( "/v1/admin/metric-thresholds", - json={ - "metric_id": admin_threshold_row["metric_id"], - "scope": "tenant", - "good": 0.0, - "warn": 0.0, - }, + json={"metric_id": admin_threshold_row["metric_id"], "scope": "tenant", "good": 0.0, "warn": 0.0}, ) assert r.status_code == 409, f"status={r.status_code} body={r.text}" assert r.json().get("status") == 409 @@ -124,10 +118,7 @@ def test_get_404_unknown(api) -> None: def test_update_200(api, admin_threshold_row: dict) -> None: - r = api.put( - f"/v1/admin/metric-thresholds/{admin_threshold_row['id']}", - json={"good": 5.0, "warn": 5.0}, - ) + r = api.put(f"/v1/admin/metric-thresholds/{admin_threshold_row['id']}", json={"good": 5.0, "warn": 5.0}) assert r.status_code == 200, f"status={r.status_code} body={r.text}" assert (r.json()["good"], r.json()["warn"]) == (5.0, 5.0) @@ -192,13 +183,13 @@ def test_update_415_wrong_content_type(api) -> None: assert r.status_code == 415, f"status={r.status_code} body={r.text}" -def test_update_403_cross_tenant(api, admin_threshold_row: dict) -> None: +def test_update_403_cross_tenant(api, admin_threshold_row: dict, other_tenant_headers: dict) -> None: """A PUT against another tenant's row fails the ownership check → 403 (the row is loaded by id, then rejected as not_tenant_admin).""" r = api.put( f"/v1/admin/metric-thresholds/{admin_threshold_row['id']}", json={"good": 1.0, "warn": 1.0}, - headers={TENANT_HEADER: OTHER_TENANT}, + headers=other_tenant_headers, ) assert r.status_code == 403, f"status={r.status_code} body={r.text}" @@ -208,10 +199,7 @@ def test_delete_400_non_uuid(api) -> None: assert r.status_code == 400, f"status={r.status_code} body={r.text}" -def test_delete_403_cross_tenant(api, admin_threshold_row: dict) -> None: +def test_delete_403_cross_tenant(api, admin_threshold_row: dict, other_tenant_headers: dict) -> None: """A DELETE against another tenant's row fails the ownership check → 403.""" - r = api.delete( - f"/v1/admin/metric-thresholds/{admin_threshold_row['id']}", - headers={TENANT_HEADER: OTHER_TENANT}, - ) + r = api.delete(f"/v1/admin/metric-thresholds/{admin_threshold_row['id']}", headers=other_tenant_headers) assert r.status_code == 403, f"status={r.status_code} body={r.text}" diff --git a/src/ingestion/tests/e2e/conftest.py b/src/ingestion/tests/e2e/conftest.py index caa5ed1ad..072e6b6c3 100644 --- a/src/ingestion/tests/e2e/conftest.py +++ b/src/ingestion/tests/e2e/conftest.py @@ -25,18 +25,18 @@ import logging import os -import pytest - from pathlib import Path +import pytest from lib import clickhouse as ch from lib import compose, mariadb from lib.analytics import AnalyticsProcess, find_free_port, locate_binary from lib.ch_seeder import CHSeeder -from lib.config import SessionConfig, TEST_TENANT_ID +from lib.config import TEST_TENANT_ID, SessionConfig from lib.dbt_runner import DbtRunner from lib.enrich import EnrichRunner -from lib.fixture_loader import TestYaml, discover_tests, load as load_test +from lib.fixture_loader import TestYaml, discover_tests +from lib.fixture_loader import load as load_test from lib.identity_stub import IdentityStub from lib.metric_seed import seed_test_metrics from lib.migration_applier import apply_all as apply_ch_migrations @@ -228,16 +228,13 @@ def _collect_metrics(proc: AnalyticsProcess) -> None: proc.base_url, "--out-dir", str(out_dir), - "--tenant", - str(TEST_TENANT_ID), + "--bearer", + proc.bearer(str(TEST_TENANT_ID)), ], check=False, ) if result.returncode != 0: - LOG.warning( - "coverage-artifact collection failed (rc=%d); gate jobs may lack inputs", - result.returncode, - ) + LOG.warning("coverage-artifact collection failed (rc=%d); gate jobs may lack inputs", result.returncode) @pytest.fixture(scope="session") @@ -271,6 +268,7 @@ def analytics(ch_migrations_applied: SessionConfig, identity_stub: IdentityStub) """ cfg = ch_migrations_applied from lib.analytics import ApiSpawnError # local import to keep top clean + try: binary = locate_binary(cfg) except ApiSpawnError as e: @@ -339,11 +337,7 @@ def pytest_generate_tests(metafunc): """Generate one `test_metric_smoke` invocation per discovered `*.test.yaml`.""" if "test_yaml" in metafunc.fixturenames and metafunc.function.__name__ == "test_metric_smoke": paths = discover_tests(_METRICS_ROOT) - metafunc.parametrize( - "test_path", - paths, - ids=[p.name[: -len(".test.yaml")] for p in paths], - ) + metafunc.parametrize("test_path", paths, ids=[p.name[: -len(".test.yaml")] for p in paths]) @pytest.fixture diff --git a/src/ingestion/tests/e2e/lib/analytics.py b/src/ingestion/tests/e2e/lib/analytics.py index 0f81d40be..8d15d8330 100644 --- a/src/ingestion/tests/e2e/lib/analytics.py +++ b/src/ingestion/tests/e2e/lib/analytics.py @@ -4,12 +4,13 @@ sessions) and spawn the binary directly on the host (per DESIGN §4: a host binary keeps target/ warm and avoids container I/O on the cargo hot path). -analytics runs auth-disabled (auth happens at the API Gateway, which we -bypass), so the gears host injects a default-tenant SecurityContext. `/health` -is served by the api-gateway host gear (public, off the tenant path). For data -routes the harness sends `X-Insight-Tenant-Id: config.TEST_TENANT_ID`, which the -tenant-override layer honors, and `metric_seed.seed_test_metrics` re-homes the -seeded metric definitions onto that tenant. The header is harmless on /health. +analytics runs auth-ENABLED (NGINX_BFF R1): the `cf-gears-oidc-authn-plugin` +verifies a signed ES256 gateway JWT on every data route and maps its `tenant_id` +claim to the request tenant. The rig mints those JWTs and serves the JWKS + +OIDC discovery over a self-signed TLS front (see lib/gateway_jwt.py); the removed +`X-Insight-Tenant-Id` header is gone. `/health` is public (host gear, no auth). +`metric_seed.seed_test_metrics` re-homes the seeded metric definitions onto +`config.TEST_TENANT_ID`, which is the tenant the harness's default bearer carries. """ from __future__ import annotations @@ -28,9 +29,11 @@ from typing import Any import httpx +import yaml from lib import api_coverage -from lib.config import SessionConfig, TENANT_HEADER, TEST_TENANT_ID +from lib.config import TEST_TENANT_ID, SessionConfig +from lib.gateway_jwt import AUDIENCE, GatewayAuth LOG = logging.getLogger("e2e.api") @@ -45,7 +48,9 @@ # nothing on the warm path and only absorbs cold-start jitter. Override on an # especially slow host (e.g. a constrained CI runner) via the env var. _HEALTH_TIMEOUT_S = float( - os.environ.get("E2E_API_HEALTH_TIMEOUT_S", "120") # RULE-DEFAULTS-OK: rig readiness ceiling, not a data-config input + os.environ.get( + "E2E_API_HEALTH_TIMEOUT_S", "120" + ) # RULE-DEFAULTS-OK: rig readiness ceiling, not a data-config input ) @@ -66,7 +71,7 @@ class ApiResponse: raw: Any = None @classmethod - def from_httpx(cls, response: httpx.Response) -> "ApiResponse": + def from_httpx(cls, response: httpx.Response) -> ApiResponse: try: body = response.json() if response.content else None except Exception: @@ -78,12 +83,7 @@ def from_httpx(cls, response: httpx.Response) -> "ApiResponse": page_info = body.get("page_info") or {} elif isinstance(body, list): items = list(body) - return cls( - status_code=response.status_code, - items=items, - page_info=page_info, - raw=body, - ) + return cls(status_code=response.status_code, items=items, page_info=page_info, raw=body) class ApiSpawnError(RuntimeError): @@ -141,6 +141,11 @@ def __init__(self, cfg: SessionConfig, binary: Path, port: int, identity_url: st # In docker mode the pytest process and the binary live in the same # container, so localhost is the same loopback either way. self.base_url = f"http://127.0.0.1:{port}" + # Gateway-JWT auth (NGINX_BFF R1): mints per-tenant ES256 bearers and + # serves the JWKS/discovery over a self-signed TLS front the plugin + # trusts. Started on construction; torn down in `stop()`. + self.auth = GatewayAuth() + self._rig_config_path: Path | None = None self._proc: subprocess.Popen[str] | None = None # Child stdout+stderr stream to a file (not a PIPE) so the startup log # can be tailed into an error message even while the process is still @@ -149,15 +154,88 @@ def __init__(self, cfg: SessionConfig, binary: Path, port: int, identity_url: st self._log_fh: Any = None self._log_path: Path | None = None + def _write_rig_config(self) -> Path: + """Write a per-spawn gears host config with the oidc-authn-plugin wired + to this rig's TLS discovery front (issuer + self-signed CA). Leaf values + (DB/ClickHouse/bind) still come from `APP__*` env overrides.""" + cfg = { + "server": {"home_dir": "/tmp"}, + "logging": {"default": {"console_level": "info"}}, + "gears": { + "api-gateway": { + "config": { + "bind_addr": f"127.0.0.1:{self.port}", + "enable_docs": True, + "cors_enabled": True, + # Auth ENABLED: the plugin verifies the signed gateway JWT. + "auth_disabled": False, + } + }, + "gear-orchestrator": {"config": {}}, + "grpc-hub": {"config": {"listen_addr": "uds:///tmp/analytics-grpc.sock"}}, + "authn-resolver": {"config": {"vendor": "hyperspot"}}, + "oidc-authn-plugin": { + "config": { + "vendor": "hyperspot", + "priority": 50, + "jwt": { + "supported_algorithms": ["ES256"], + "clock_skew_leeway": "60s", + "require_audience": True, + "expected_audience": [AUDIENCE], + "trusted_issuers": [{"issuer": self.auth.issuer}], + "claim_mapping": { + "subject_id": "sub", + "subject_tenant_id": "tenant_id", + "subject_type": "sub_type", + "token_scopes": "roles", + }, + "required_claims": [], + }, + "http_client": {"request_timeout": "5s", "custom_ca_certificate_paths": [self.auth.ca_path]}, + "s2s_oauth": { + "discovery_url": self.auth.issuer, + "default_subject_type": "service", + "token_cache": {"ttl": "300s", "max_entries": 100}, + }, + } + }, + "authz-resolver": {"config": {"vendor": "hyperspot"}}, + "static-authz-plugin": {"config": {"vendor": "hyperspot", "priority": 100}}, + "tenant-resolver": {"config": {"vendor": "hyperspot"}}, + "single-tenant-tr-plugin": {"config": {"vendor": "hyperspot", "priority": 20}}, + "analytics": { + "config": { + "database_url": "", + "clickhouse_url": "", + "clickhouse_database": "insight", + "identity_url": "", + "redis_url": "", + "metric_catalog": {"tenant_default_id": None}, + } + }, + }, + } + fh = tempfile.NamedTemporaryFile( # noqa: SIM115 — path used by the spawned binary + mode="w", suffix=".yaml", prefix=f"analytics-cfg-{self.port}-", delete=False + ) + yaml.safe_dump(cfg, fh, sort_keys=False) + fh.close() + self._rig_config_path = Path(fh.name) + return self._rig_config_path + def start(self) -> None: env = os.environ.copy() # analytics is now a gears-rust host (toolkit::bootstrap::run_server): - # the `api-gateway` system gear is the REST host and structural config - # (gears list, auth_disabled, resolvers, grpc-hub) lives in the checked-in - # config file, which we pass with `-c`. Per-run values are injected as - # `APP__*` env overrides (direct Popen execve preserves the hyphenated - # gear-name segments, unlike the compose sh entrypoint). - config_path = self.cfg.analytics_manifest_dir / "config" / "insight.yaml" + # the `api-gateway` system gear is the REST host. This rig runs analytics + # auth-ENABLED (NGINX_BFF R1): tenant comes from a signed gateway JWT, not + # a header. The oidc-authn-plugin verification config (issuer + self- + # signed CA of `self.auth`'s TLS discovery front) is nested/list-shaped + # and not env-injectable, so it is written into a per-spawn config file + # passed with `-c`. Per-run scalar values are injected as `APP__*` env + # overrides (direct Popen execve preserves the hyphenated gear-name + # segments, unlike the compose sh entrypoint). + config_path = self._write_rig_config() # bind_addr: loopback-only (PRD cpt-bronze-to-api-e2e-constraint-loopback-only). # The REST host bind belongs to the api-gateway gear, so override it there. bind_addr = f"127.0.0.1:{self.port}" @@ -172,17 +250,15 @@ def start(self) -> None: "APP__gears__analytics__config__clickhouse_database": self.cfg.ch_database, "APP__gears__analytics__config__clickhouse_user": self.cfg.ch_user, "APP__gears__analytics__config__clickhouse_password": self.cfg.ch_password, - # Single-tenant catalog-resolution hint. Under auth_disabled the - # host injects DEFAULT_TENANT_ID; the rig additionally sends - # `X-Insight-Tenant-Id: TEST_TENANT_ID` on every request, which - # the tenant-override layer honors. Platform metric definitions + # Single-tenant catalog-resolution hint. The request tenant comes + # from the signed JWT's `tenant_id`; platform metric definitions # seed under GLOBAL_TENANT (nil) and stay visible via # `InsightTenantId IN [tenant, nil]`, so this need not match the # seeded bronze tenant. "APP__gears__analytics__config__metric_catalog__tenant_default_id": "00000000-0000-0000-0000-000000000001", # No redis_url — leave config default (empty). "RUST_LOG": env.get("RUST_LOG", "info"), - }, + } ) # Only override identity_url when the rig wired a stub (see __init__). if self.identity_url: @@ -194,11 +270,7 @@ def start(self) -> None: mode="w", suffix=".log", prefix=f"analytics-{self.port}-", delete=False ) self._log_path = Path(self._log_fh.name) - LOG.info( - "spawning analytics (gears host) on 127.0.0.1:%d (startup log: %s)", - self.port, - self._log_path, - ) + LOG.info("spawning analytics (gears host) on 127.0.0.1:%d (startup log: %s)", self.port, self._log_path) self._proc = subprocess.Popen( [str(self.binary), "-c", str(config_path), "run"], env=env, @@ -233,20 +305,33 @@ def stop(self) -> None: except OSError: pass self._log_path = None + # Tear down the gateway-JWT TLS front + its per-spawn config file. + self.auth.stop() + if self._rig_config_path is not None: + try: + self._rig_config_path.unlink(missing_ok=True) + except OSError: + pass + self._rig_config_path = None def is_running(self) -> bool: return self._proc is not None and self._proc.poll() is None + def bearer(self, tenant_id: str) -> str: + """A signed gateway JWT scoped to `tenant_id` (for cross-tenant cases).""" + return self.auth.mint(tenant_id) + def client(self) -> httpx.Client: """Return an httpx.Client bound to this process's base URL. - Every request carries `X-Insight-Tenant-Id` so the tenant-override layer - pins data routes to the test tenant. `/health` (host gear) ignores it. + Every request carries a signed gateway JWT (Authorization: Bearer) whose + `tenant_id` claim is the test tenant — analytics verifies it and maps the + claim to the request tenant (NGINX_BFF R1). `/health` is public. """ return httpx.Client( base_url=self.base_url, timeout=30.0, - headers={TENANT_HEADER: str(TEST_TENANT_ID)}, + headers=self.auth.auth_header(str(TEST_TENANT_ID)), # Record every (method, path, status) the suite exercises so the # endpoint-coverage gate (lib/api_coverage.py) can diff it # against the OpenAPI spec. This is THE chokepoint — metric tests @@ -302,17 +387,10 @@ def _wait_healthy(self, *, timeout_s: float) -> None: while time.monotonic() < deadline: if not self.is_running(): code = self._proc.returncode if self._proc else "?" - raise ApiSpawnError( - f"analytics exited during startup (code={code}):\n" - f"{self._read_log_tail()}" - ) + raise ApiSpawnError(f"analytics exited during startup (code={code}):\n{self._read_log_tail()}") try: - with httpx.Client( - base_url=self.base_url, - timeout=2.0, - headers={TENANT_HEADER: str(TEST_TENANT_ID)}, - ) as c: - r = c.get("/health") + with httpx.Client(base_url=self.base_url, timeout=2.0) as c: + r = c.get("/health") # public host-gear route; no auth needed if r.status_code == 200: LOG.info("analytics is healthy at %s", self.base_url) return @@ -332,8 +410,8 @@ def _wait_healthy(self, *, timeout_s: float) -> None: @contextmanager def spawn(cfg: SessionConfig): - """Context manager: build (if needed), spawn, yield, stop.""" - binary = build(cfg) + """Context manager: locate the binary, spawn, yield, stop.""" + binary = locate_binary(cfg) port = find_free_port() proc = AnalyticsProcess(cfg, binary, port) proc.start() diff --git a/src/ingestion/tests/e2e/lib/collect_metrics.py b/src/ingestion/tests/e2e/lib/collect_metrics.py index 3f0310903..667ca04da 100755 --- a/src/ingestion/tests/e2e/lib/collect_metrics.py +++ b/src/ingestion/tests/e2e/lib/collect_metrics.py @@ -9,7 +9,7 @@ Standalone: python3 lib/collect_metrics.py \ --url http://127.0.0.1:8081 --out-dir .artifacts \ - --tenant 00000000-0000-0000-0000-000000000001 + --bearer "" """ from __future__ import annotations @@ -19,7 +19,7 @@ from pathlib import Path -def collect(base_url: str, out_dir: str | Path, tenant_id: str | None) -> None: +def collect(base_url: str, out_dir: str | Path, bearer: str | None) -> None: """Fetch the metric catalog and write it to ``out_dir``. Fail-fast (raises) if the response is empty — a missing/empty artifact would @@ -28,9 +28,10 @@ def collect(base_url: str, out_dir: str | Path, tenant_id: str | None) -> None: import httpx # local import: keeps this importable without httpx headers: dict[str, str] = {} - if tenant_id: - # the catalog read is tenant-gated. - headers["X-Insight-Tenant-Id"] = tenant_id + if bearer: + # Analytics verifies the signed gateway JWT (NGINX_BFF R1); its + # `tenant_id` claim scopes the tenant-gated catalog read. + headers["Authorization"] = f"Bearer {bearer}" with httpx.Client(base_url=base_url, timeout=30.0, headers=headers) as c: catalog = c.post("/v1/catalog/get_metrics", json={}) catalog.raise_for_status() @@ -42,18 +43,16 @@ def collect(base_url: str, out_dir: str | Path, tenant_id: str | None) -> None: out = Path(out_dir) out.mkdir(parents=True, exist_ok=True) (out / "catalog_metrics.json").write_text(json.dumps(catalog_doc, indent=2) + "\n", encoding="utf-8") - print(f"collected catalog_metrics.json ({len(catalog_doc['metrics'])} metrics) -> {out}") + print(f"collected catalog_metrics.json ({len(catalog_doc['metrics'])} metrics) -> {out}") # noqa: T201 def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser( - description="Snapshot the analytics metric catalog for the coverage gate." - ) + p = argparse.ArgumentParser(description="Snapshot the analytics metric catalog for the coverage gate.") p.add_argument("--url", required=True, help="analytics base URL") p.add_argument("--out-dir", required=True, help="directory to write the artifact into") - p.add_argument("--tenant", help="X-Insight-Tenant-Id header (the catalog read is tenant-gated)") + p.add_argument("--bearer", help="signed gateway JWT (Authorization: Bearer); the catalog read is tenant-gated") args = p.parse_args(argv) - collect(args.url, args.out_dir, args.tenant) + collect(args.url, args.out_dir, args.bearer) return 0 diff --git a/src/ingestion/tests/e2e/lib/config.py b/src/ingestion/tests/e2e/lib/config.py index c4fc25afe..080cf2b60 100644 --- a/src/ingestion/tests/e2e/lib/config.py +++ b/src/ingestion/tests/e2e/lib/config.py @@ -14,23 +14,19 @@ from dataclasses import dataclass, field from pathlib import Path - # Resolve the repo root from this file's location: # src/ingestion/tests/e2e/lib/config.py -> ../../../../../ _REPO_ROOT = Path(__file__).resolve().parents[5] -# Header the analytics service's tenant middleware reads to resolve the request tenant -# (auth.rs::TENANT_HEADER). The harness sends it on EVERY request. -TENANT_HEADER = "X-Insight-Tenant-Id" - -# Session tenant for the whole e2e run. The analytics service's tenant middleware -# rejects the nil UUID (a non-identity value must not pin tenant context), so -# the harness cannot use 0000…0. Instead it seeds metric definitions under this -# non-nil tenant and sends it as `X-Insight-Tenant-Id` on every request. The -# ClickHouse query path does not filter by tenant yet (MVP — handlers.rs), so -# fixture data carries whatever tenant it likes; only the `metrics`-table lookup -# is tenant-scoped, and that is what we align here. +# Session tenant for the whole e2e run. Analytics runs auth-ENABLED +# (NGINX_BFF R1): the harness sends a signed gateway JWT carrying this tenant in +# its `tenant_id` claim (see lib/gateway_jwt.py) — the removed +# `X-Insight-Tenant-Id` header is gone. The tenant middleware rejects the nil +# UUID, so the harness uses this non-nil tenant, seeds metric definitions under +# it, and mints per-tenant bearers. The ClickHouse query path does not filter by +# tenant yet (MVP — handlers.rs), so fixture data carries whatever tenant it +# likes; only the `metrics`-table lookup is tenant-scoped. TEST_TENANT_ID = uuid.UUID("11111111-1111-1111-1111-111111111111") @@ -80,7 +76,7 @@ class SessionConfig: mariadb_root_password: str = field(default_factory=_random_password) @classmethod - def from_env(cls) -> "SessionConfig": + def from_env(cls) -> SessionConfig: repo_root = Path(os.environ.get("INSIGHT_REPO_ROOT", _REPO_ROOT)).resolve() run_mode = os.environ.get("E2E_RUN_MODE", "host") if run_mode == "docker": diff --git a/src/ingestion/tests/e2e/lib/gateway_jwt.py b/src/ingestion/tests/e2e/lib/gateway_jwt.py new file mode 100644 index 000000000..4afc021b9 --- /dev/null +++ b/src/ingestion/tests/e2e/lib/gateway_jwt.py @@ -0,0 +1,191 @@ +"""Dev/e2e gateway-JWT auth for the full-auth ingestion rig. + +The bronze->API rig runs analytics auth-ENABLED (NGINX_BFF R1): each request +carries a signed ES256 gateway JWT whose `tenant_id` claim is the sole tenant +authority (the removed `X-Insight-Tenant-Id` dev header is gone). Analytics' +`cf-gears-oidc-authn-plugin` verifies the JWT and resolves the JWKS via OIDC +discovery over HTTPS ONLY, so this module: + + * generates an ephemeral EC P-256 signing key (kid = RFC 7638 thumbprint), + * serves `/.well-known/openid-configuration` + `/.well-known/jwks.json` from a + self-signed TLS front on loopback (SAN 127.0.0.1), trusted by the plugin via + `http_client.custom_ca_certificate_paths`, + * mints per-tenant gateway JWTs for the harness to send as `Authorization`. + +Everything is loopback + ephemeral; nothing here is a production credential. +""" + +from __future__ import annotations + +import base64 +import datetime as _dt +import hashlib +import http.server +import ipaddress +import ssl +import tempfile +import threading +import time +import uuid +from pathlib import Path + +import jwt as _jwt +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +AUDIENCE = "internal-services" + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +class GatewayAuth: + """Owns the signing key, the TLS discovery front, and per-tenant minting.""" + + def __init__(self) -> None: + self._key = ec.generate_private_key(ec.SECP256R1()) + self._key_pem = self._key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + + nums = self._key.public_key().public_numbers() + x_b64 = _b64url(nums.x.to_bytes(32, "big")) + y_b64 = _b64url(nums.y.to_bytes(32, "big")) + # RFC 7638 EC thumbprint — must match the authenticator's kid scheme so + # the plugin finds this key in the served JWKS. + canonical = f'{{"crv":"P-256","kty":"EC","x":"{x_b64}","y":"{y_b64}"}}' + self._kid = _b64url(hashlib.sha256(canonical.encode()).digest()) + self._jwk = { + "kty": "EC", + "crv": "P-256", + "x": x_b64, + "y": y_b64, + "use": "sig", + "alg": "ES256", + "kid": self._kid, + } + + self._certs_dir = Path(tempfile.mkdtemp(prefix="e2e-gwauth-")) + self._ca_path = self._certs_dir / "ca.pem" + self._start_tls_front() + + # -- TLS discovery front --------------------------------------------------- + + def _start_tls_front(self) -> None: + cert_pem, key_pem = self._self_signed_cert() + cert_path = self._certs_dir / "server.pem" + key_path = self._certs_dir / "server.key" + cert_path.write_bytes(cert_pem) + key_path.write_bytes(key_pem) + # The plugin trusts the self-signed leaf directly as a root. + self._ca_path.write_bytes(cert_pem) + + # Bind to an ephemeral port; discover it after bind. + docs = self._docs # bound below once we know the port + auth = self + + class _Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + body = docs().get(self.path) + if body is None: + self.send_response(404) + self.end_headers() + return + encoded = body.encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, *_a): # silence access logging + pass + + self._server = http.server.HTTPServer(("127.0.0.1", 0), _Handler) + self._port = self._server.server_address[1] + self.issuer = f"https://127.0.0.1:{self._port}" + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + self._server.socket = ctx.wrap_socket(self._server.socket, server_side=True) + + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + # Bind the doc bodies now that the issuer/port are known. + _ = auth.issuer + + def _self_signed_cert(self) -> tuple[bytes, bytes]: + key = ec.generate_private_key(ec.SECP256R1()) + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "e2e-gwauth")]) + now = _dt.datetime(2020, 1, 1, tzinfo=_dt.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + _dt.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False + ) + .sign(key, hashes.SHA256()) + ) + return ( + cert.public_bytes(serialization.Encoding.PEM), + key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ), + ) + + def _docs(self) -> dict[str, str]: + import json + + return { + "/.well-known/openid-configuration": json.dumps( + { + "issuer": self.issuer, + "jwks_uri": f"{self.issuer}/.well-known/jwks.json", + "id_token_signing_alg_values_supported": ["ES256"], + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + } + ), + "/.well-known/jwks.json": json.dumps({"keys": [self._jwk]}), + } + + # -- accessors ------------------------------------------------------------- + + @property + def ca_path(self) -> str: + return str(self._ca_path) + + def mint(self, tenant_id: str, *, sub: str | None = None, roles: str = "analyst") -> str: + """Sign an ES256 gateway JWT scoped to `tenant_id`.""" + now = int(time.time()) + claims = { + "sub": sub or str(uuid.uuid4()), + "tenant_id": tenant_id, + "roles": roles, # space-delimited scope string + "sub_type": "user", + "sid": str(uuid.uuid4()), + "iss": self.issuer, + "aud": AUDIENCE, + "iat": now, + "exp": now + 3600, + "jti": str(uuid.uuid4()), + } + return _jwt.encode(claims, self._key_pem, algorithm="ES256", headers={"kid": self._kid}) + + def auth_header(self, tenant_id: str) -> dict[str, str]: + return {"Authorization": f"Bearer {self.mint(tenant_id)}"} + + def stop(self) -> None: + try: + self._server.shutdown() + self._server.server_close() + except Exception: # noqa: BLE001 — best-effort teardown + pass diff --git a/src/ingestion/tests/e2e/lib/metric_coverage.py b/src/ingestion/tests/e2e/lib/metric_coverage.py index 36e0bad0a..1d4f42ec0 100644 --- a/src/ingestion/tests/e2e/lib/metric_coverage.py +++ b/src/ingestion/tests/e2e/lib/metric_coverage.py @@ -40,10 +40,6 @@ import yaml -# The tenant header the API requires (mirrors lib.config.TENANT_HEADER). Any -# non-nil tenant resolves the middleware; the catalog rows are tenant-NULL -# (global), so `get_metrics` returns them for any resolved tenant. -TENANT_HEADER = "X-Insight-Tenant-Id" DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001" # lib/metric_coverage.py -> lib/ -> e2e/ @@ -114,8 +110,7 @@ def resolve_skips(universe: dict[str, str]) -> tuple[dict[str, str], set[str]]: raise ValueError(f"duplicate metric_key in SKIP_LIST: {key}") if _vector(key) in SKIP_TABLES: raise ValueError( - f"SKIP_LIST key {key} is already covered by SKIP_TABLES[{_vector(key)!r}] — " - f"drop the per-key entry." + f"SKIP_LIST key {key} is already covered by SKIP_TABLES[{_vector(key)!r}] — drop the per-key entry." ) explicit[key] = reason resolved = dict(explicit) @@ -134,17 +129,19 @@ def _universe_from_body(body: object) -> dict[str, str]: return {str(m["metric_key"]): str(m.get("label", "")) for m in metrics} -def universe_from_url(base_url: str, tenant_id: str = DEFAULT_TENANT_ID) -> dict[str, str]: +def universe_from_url(base_url: str, bearer: str | None = None) -> dict[str, str]: """`{metric_key: label}` from `POST {base_url}/v1/catalog/get_metrics` — the enabled product metric_keys (dotted `.`). - Sourced from the API (not a raw `metric_catalog` SELECT) so the gate checks - the contract consumers see; the endpoint already returns exactly the enabled - catalog rows. + Dev-only fallback (the CI gate reads the collected artifact via + `universe_from_file`). Analytics is auth-enabled, so a live read needs a + signed gateway JWT (`ANALYTICS_BEARER`); the catalog rows are tenant-NULL + (global) so any valid token's tenant sees them. """ import httpx # local import: keeps the pure logic importable without httpx - with httpx.Client(base_url=base_url, timeout=30.0, headers={TENANT_HEADER: tenant_id}) as c: + headers = {"Authorization": f"Bearer {bearer}"} if bearer else {} + with httpx.Client(base_url=base_url, timeout=30.0, headers=headers) as c: resp = c.post("/v1/catalog/get_metrics", json={}) resp.raise_for_status() body = resp.json() @@ -236,11 +233,7 @@ def __post_init__(self) -> None: @property def passed(self) -> bool: return not ( - self.uncovered - or self.redundant_skips - or self.stale_skips - or self.stale_tables - or self.unknown_asserted + self.uncovered or self.redundant_skips or self.stale_skips or self.stale_tables or self.unknown_asserted ) def files_for(self, full_key: str) -> set[str]: @@ -252,10 +245,7 @@ def build_report(universe: dict[str, str], metrics_dir: Path = METRICS_DIR) -> C metric_keys the API serves); asserted + skips are local to the rig.""" resolved, explicit = resolve_skips(universe) return CoverageReport( - universe=universe, - asserted=asserted_keys_from_tests(metrics_dir), - skips=resolved, - explicit_skips=explicit, + universe=universe, asserted=asserted_keys_from_tests(metrics_dir), skips=resolved, explicit_skips=explicit ) @@ -270,14 +260,10 @@ def gate_violations(r: CoverageReport) -> list[str]: ) for k in sorted(r.redundant_skips): files = ", ".join(sorted(r.files_for(k))) - out.append( - f"FAIL `{k}` — skip-listed but now value-tested by [{files}]. Remove its entry " - f"from {_WHERE}." - ) + out.append(f"FAIL `{k}` — skip-listed but now value-tested by [{files}]. Remove its entry from {_WHERE}.") for k in sorted(r.stale_skips): out.append( - f"FAIL `{k}` — skip-listed but no longer a catalog metric_key (removed/renamed). " - f"Remove it from {_WHERE}." + f"FAIL `{k}` — skip-listed but no longer a catalog metric_key (removed/renamed). Remove it from {_WHERE}." ) for t in sorted(r.stale_tables): out.append( @@ -342,9 +328,7 @@ def _skips_by_reason(r: CoverageReport) -> list[tuple[str, int]]: def render_markdown(r: CoverageReport) -> str: """Markdown report: a per-vector summary + the reachable backlog up top, then the full per-key detail (collapsed), then a skip-list-hygiene footer.""" - cov, skp, tot, miss = ( - len(r.covered), len(r.skipped_active), len(r.universe), len(r.uncovered), - ) + cov, skp, tot, miss = (len(r.covered), len(r.skipped_active), len(r.universe), len(r.uncovered)) out = [ "# Metric coverage — by metric_key", "", @@ -355,9 +339,13 @@ def render_markdown(r: CoverageReport) -> str: # ── Per-vector summary ─────────────────────────────────────────────────── tables = _by_table(r.universe) - out += ["", "## Coverage by vector", "", - "| vector | tested | skipped | missing | coverage |", - "|---|--:|--:|--:|--:|"] + out += [ + "", + "## Coverage by vector", + "", + "| vector | tested | skipped | missing | coverage |", + "|---|--:|--:|--:|--:|", + ] for t in sorted(tables, key=lambda x: (-sum(1 for k in tables[x] if k in r.covered), x)): keys = tables[t] c = sum(1 for k in keys if k in r.covered) @@ -376,20 +364,27 @@ def render_markdown(r: CoverageReport) -> str: # ── Reachable backlog (fixtures exist — just write the assertion) ───────── backlog = sorted(k for k in r.skipped_active if _is_reachable(r.skips[k])) if backlog: - out += ["", f"## Reachable now — backlog ({len(backlog)})", - "_Fixtures already exist; each just needs a `find:`+`equal` assertion in a test._", - ""] + out += [ + "", + f"## Reachable now — backlog ({len(backlog)})", + "_Fixtures already exist; each just needs a `find:`+`equal` assertion in a test._", + "", + ] for k in backlog: out.append(f"- **{r.universe[k] or suffix(k)}** — `{suffix(k)}` ({_vector_name(_vector(k))})") # ── Full per-key detail (collapsed) ────────────────────────────────────── - out += ["", "
Per-key detail (all " - f"{tot})", ""] + out += ["", f"
Per-key detail (all {tot})", ""] for t in sorted(tables): keys = sorted(tables[t]) c = sum(1 for k in keys if k in r.covered) - out += ["", f"### {_vector_name(t)} (`{t}`) — {c}/{len(keys)}", "", - "| status | metric | key | detail |", "|---|---|---|---|"] + out += [ + "", + f"### {_vector_name(t)} (`{t}`) — {c}/{len(keys)}", + "", + "| status | metric | key | detail |", + "|---|---|---|---|", + ] for k in keys: col, label = suffix(k), (r.universe[k] or suffix(k)) if k in r.uncovered: @@ -403,11 +398,15 @@ def render_markdown(r: CoverageReport) -> str: # ── Skip-list hygiene (these also fail the gate) ───────────────────────── hygiene: list[str] = [] for k in sorted(r.redundant_skips): - hygiene.append(f"- `{k}` skip-listed but now tested by [{', '.join(sorted(r.files_for(k)))}]; remove from SKIP_LIST.") + hygiene.append( + f"- `{k}` skip-listed but now tested by [{', '.join(sorted(r.files_for(k)))}]; remove from SKIP_LIST." + ) for k in sorted(r.stale_skips): hygiene.append(f"- `{k}` skip-listed but no longer in the catalog; remove from SKIP_LIST.") for bare in sorted(r.unknown_asserted): - hygiene.append(f"- `{bare}` asserted by [{', '.join(sorted(r.asserted[bare]))}] is not a catalog metric_key (typo/unseeded).") + hygiene.append( + f"- `{bare}` asserted by [{', '.join(sorted(r.asserted[bare]))}] is not a catalog metric_key (typo/unseeded)." + ) if hygiene: out += ["", "## Skip-list issues (also fail the gate)", *hygiene] return "\n".join(out) + "\n" @@ -425,8 +424,7 @@ def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description="Metric-coverage gate (by metric_key).") p.add_argument( "--universe-file", - help="catalog_metrics.json (a saved POST /v1/catalog/get_metrics response); " - "default: fetch from $ANALYTICS_URL", + help="catalog_metrics.json (a saved POST /v1/catalog/get_metrics response); default: fetch from $ANALYTICS_URL", ) args = p.parse_args(argv) @@ -435,23 +433,23 @@ def main(argv: list[str] | None = None) -> int: else: url = os.environ.get("ANALYTICS_URL") if not url: - print( + print( # noqa: T201 "metric coverage: pass --universe-file or set " "ANALYTICS_URL to a running analytics.", file=sys.stderr, ) return 2 - universe = universe_from_url(url, os.environ.get("ANALYTICS_TENANT_ID", DEFAULT_TENANT_ID)) + universe = universe_from_url(url, os.environ.get("ANALYTICS_BEARER")) report = build_report(universe) if not report.universe: - print( + print( # noqa: T201 "metric coverage: empty universe — the catalog isn't seeded (live) or the " "collected catalog_metrics.json is empty.", file=sys.stderr, ) return 1 - print(render_markdown(report)) + print(render_markdown(report)) # noqa: T201 return 0 if report.passed else 1 diff --git a/src/ingestion/tests/e2e/lib/metric_seed.py b/src/ingestion/tests/e2e/lib/metric_seed.py index 23c4ca8c0..5d810be59 100644 --- a/src/ingestion/tests/e2e/lib/metric_seed.py +++ b/src/ingestion/tests/e2e/lib/metric_seed.py @@ -18,7 +18,7 @@ import yaml from lib import mariadb -from lib.config import SessionConfig, TEST_TENANT_ID +from lib.config import TEST_TENANT_ID, SessionConfig LOG = logging.getLogger("e2e.metric_seed") @@ -26,7 +26,7 @@ # middleware rejects the nil UUID, and `find_enabled_metric` filters the # `metrics` table by tenant, so both the prod metrics seeded by the binary's # migrations (under the nil tenant) and our overrides must sit under the tenant -# the harness sends as `X-Insight-Tenant-Id`. +# the harness's signed gateway JWT carries in its `tenant_id` claim. # SeaORM stores `.uuid()` columns as BINARY(16) in MariaDB, so we pass raw # bytes — pymysql interprets a str as utf-8 (36 chars) and overflows. TEST_TENANT = TEST_TENANT_ID.bytes @@ -66,10 +66,7 @@ def _retenant_seeded_metrics(cur) -> int: seeded under 0000…0 are invisible to a request that resolves to TEST_TENANT. Re-homing them in the test DB (NOT in the migration source) keeps the fix inside the harness and out of prod seeding. Idempotent.""" - cur.execute( - "UPDATE metrics SET insight_tenant_id = %s WHERE insight_tenant_id = %s", - (TEST_TENANT, NIL_TENANT), - ) + cur.execute("UPDATE metrics SET insight_tenant_id = %s WHERE insight_tenant_id = %s", (TEST_TENANT, NIL_TENANT)) return cur.rowcount diff --git a/src/ingestion/tests/e2e/pyproject.toml b/src/ingestion/tests/e2e/pyproject.toml index 96918d903..e43bacfd6 100644 --- a/src/ingestion/tests/e2e/pyproject.toml +++ b/src/ingestion/tests/e2e/pyproject.toml @@ -13,6 +13,10 @@ dependencies = [ "clickhouse-connect>=0.7", "pymysql>=1.1", "httpx>=0.27", + # Mint ES256 gateway JWTs + a self-signed JWKS/discovery TLS front so the + # rig runs analytics auth-ENABLED (NGINX_BFF R1); see lib/gateway_jwt.py. + "pyjwt>=2.8", + "cryptography>=42", "pandas>=2.2", "dbt-core>=1.9", "dbt-clickhouse>=1.9",