diff --git a/docs/phase-4-runbook.md b/docs/phase-4-runbook.md index d6c0894..1e05e5a 100644 --- a/docs/phase-4-runbook.md +++ b/docs/phase-4-runbook.md @@ -60,18 +60,29 @@ Docker-free testing could have caught: `/docs/security/threat-model.md`'s "Read this first" section for the full account. -**What's still not verified, and why**: §3a (OIDC) and §3b (SAML) both -need a real external IdP (Auth0/Okta developer tenant, or similar) that -this environment doesn't have credentials for -- the fake-IdP tests -(real RS256/XML crypto, no stand-in shortcuts) are the strongest -evidence available without one. §12's frontend-against-a-real- -`enterprise-auth`-container check is blocked by the same root cause: -there's no way to land on `/select-tenant` with a genuine pending-login -cookie from the real binary without a real IdP redirecting through it -first. §7 and §11's live-cluster steps need `kind`/`kubectl`, which -aren't installed in this environment -- their offline-only checks -(`go build`/`go vet`/`go test`, `helm lint`, `helm template` + parsing -the rendered YAML) all pass and are documented as such below. +**What's still not verified, and why**: §3a (OIDC) and §12 (tenant +picker, both single- and multi-membership paths) are now closed -- +verified against a real Auth0 developer tenant, full browser round +trips, real session cookies authorizing correctly, including selecting +between two real tenant memberships and getting back the right +tenant/role each time (see §3a and §12 below). That pass also found and +fixed a real bug: `web/Dockerfile` only declared `ARG`/`ENV` for +`VITE_API_BASE_URL`, so `docker-compose.yml`'s build args for +`VITE_ALERTING_API_BASE_URL`/`VITE_ENTERPRISE_AUTH_BASE_URL` were +silently dropped by Docker (an undeclared `--build-arg` is dropped, not +an error) -- `enterpriseAuthBase` came out `undefined` in the built +bundle, so the tenant-picker page threw "enterprise-auth is not +configured" against a real running container even though +`docker-compose.yml` looked correct. Fixed by declaring all three. + +§3b (SAML) still needs a real external IdP with SAML app support, which +this environment doesn't have credentials for -- the fake-IdP test (real +XML signing/verification, no stand-in shortcuts) is the strongest +evidence available without one. §7 and §11's live-cluster steps need +`kind`/`kubectl`, which aren't installed in this environment -- their +offline-only checks (`go build`/`go vet`/`go test`, `helm lint`, `helm +template` + parsing the rendered YAML) all pass and are documented as +such below. If you're reading this to decide whether Phase 4 is production-ready: closer than before, but not yet -- see @@ -128,18 +139,30 @@ curl -s http://localhost:8082/auth/features # (no OIDC_ISSUER_URL/SAML_IDP_METADATA_URL set in this compose file) ``` -## 3a. `enterprise-auth`: human login via OIDC (new -- unlike everything -else in this runbook, the underlying flow *was* verified live in this -session, just not against a real running `enterprise-auth` container or -a real external IdP) +## 3a. `enterprise-auth`: human login via OIDC (now genuinely verified +against a real external IdP, not just a real fake one) `enterprise/internal/loginhandler`'s tests already prove the mechanism works end to end against a real fake IdP (`go test ./internal/loginhandler/... -v` from `enterprise/`, no Docker needed -- -see `enterprise/README.md`). What's still unverified is wiring it into -this actual running stack. To try that for real, point -`docker-compose.yml`'s `enterprise-auth` service at a real OIDC IdP -(a free Auth0/Okta developer tenant, or any IdP you control): +see `enterprise/README.md`). **Closed for real in this pass**: a free +Auth0 developer tenant was created, wired into a local-only +`docker-compose.override.yml` (never committed -- see `.gitignore`), and +the full human login flow was driven through a real browser end to end: +`GET /auth/oidc/login` redirected to Auth0's real hosted login page; the +first attempt (before any `tenant_memberships` row existed) correctly +failed closed with "this identity has no tenant membership" while still +creating the `users` row via `UpsertUserBySSO`, exactly as designed; +after `-grant-membership-*` granted that real Auth0 identity +(`john@linuxexperts.net`) an `admin` role on `acme`, a second login +completed the full OAuth code exchange, consent screen, and redirect to +`web`, landing a real `sentry_session` cookie; `POST +/internal/authorize` with that cookie returned +`{"tenant_id":"acme","user_id":"...","role":"admin"}` -- the exact +membership granted, round-tripped through a real external IdP, not a +stand-in. To reproduce, point `docker-compose.yml`'s `enterprise-auth` +service at a real OIDC IdP (a free Auth0/Okta developer tenant, or any +IdP you control): ```sh # Add to enterprise-auth's environment in docker-compose.yml (or a @@ -656,6 +679,26 @@ frontend-adjacent (`getAuthFeatures` on the settings page, existing Phase 0-3 routes) predates this runbook and was never re-verified here either. +**Since closed for real against the actual running containers**, not a +stand-in: `docker-compose.override.yml` (local-only, gitignored) pointed +`enterprise-auth` at the same real Auth0 developer tenant §3a used. Two +real tenants (`acme`, `globex`) each got a real `tenant_memberships` row +for the same Auth0 identity (`admin` and `viewer` respectively, via +`-grant-membership-*`). Logging in through `/auth/oidc/login` against +the real IdP landed on the real `/select-tenant` page, which rendered +both real memberships with their real display names and roles ("Acme +Corp -- Admin", "Globex Corporation -- Viewer") -- fetched via a real +credentialed cross-origin request to the real `enterprise-auth` +container, not the fake Node stand-in. Selecting "Globex Corporation" +fired a real `POST /auth/select-tenant`, redirected to `web`, and +`POST /internal/authorize` with the resulting cookie returned +`{"tenant_id":"globex","user_id":"...","role":"viewer"}` -- proving the +picker's selection genuinely determines the issued session's tenant, not +just that the UI renders correctly. This run is also what found and +fixed the `web/Dockerfile` build-arg bug described in this doc's top +"Verification status" section -- the picker was unreachable against the +real container until that fix landed. + ## 13. Ingest tenant identity The identity mechanism was chosen deliberately (config-supplied diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 2f2bbec..550f72d 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -51,13 +51,21 @@ Neither engine does a live per-write check (a database/HTTP round trip per record would be a real throughput cost neither implementation accepts), so a minute-wide staleness window remains on both sides by design, not by oversight. What's left: two tenants (`acme`, `globex`) -have been provisioned and exercised end-to-end in this environment, but -only via direct ClickHouse-user connections and Go integration tests — -not yet via a real logged-in human session walking through `POST -/query` in a browser, since that still needs OIDC/SAML wired to a real -external IdP (see §3a/§3b below). And **whether a given deployment -actually runs the isolated binaries** remains a deployment-time -decision, not a code-level guarantee — see below. +have been provisioned and exercised end-to-end in this environment. +Human login itself is now verified for real — a real Auth0 identity +logged in via OIDC, selected between both tenants, and +`POST /internal/authorize` confirmed each selection issued the right +tenant/role (see §3a/§12 below) — but that walkthrough ran against plain +`api` serving `web`'s traffic, not `enterprise-api`, so the specific +combination of "real human OIDC session" and "real per-tenant ClickHouse +routing via `chrunner`" in the same request hasn't been driven end to +end yet; each half is independently confirmed (real ClickHouse +connections and Go integration tests for the routing half, real Auth0 +sessions for the human-login half), just not together in one request. +SAML's human-login half still needs a real external IdP with SAML app +support (see §3b below). And **whether a given deployment actually runs +the isolated binaries** remains a deployment-time decision, not a +code-level guarantee — see below. **ClickHouse (the SQL path) is built and now genuinely verified live.** `enterprise/internal/tenantprovision` (real `CREATE DATABASE`/`CREATE @@ -554,9 +562,9 @@ terms: | Ingest tenant *write-routing*, Tantivy | **Built and genuinely verified** — `search/src/consumer.rs` routes each record into its own tenant's index via `IndexRegistry`, same registry the (already-verified) read side uses; no Docker needed, real tests pass. Active-tenant-gated too: `tenants::ActiveTenantTracker` polls `enterprise-auth` every 60s (off unless configured), refusing any tenant not in the polled allowlist — same one-minute staleness bound as ClickHouse's now-refreshing snapshot, no more asymmetry between the two | | Deployment actually routing traffic to `enterprise-api` (Helm) | **Enforced** — `api`/`enterprise-api` are mutually exclusive, same flag as RBAC/audit/SSO | | Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Enforced, verified live** — `api`/`enterprise-api` are mutually exclusive via `COMPOSE_PROFILES`, same flag choice as Helm's `enterprise.enabled`; a real `docker compose up` of `enterprise-api` was run in this environment (and caught/fixed a startup-crashing duplicate `GET /healthz` route registration bug in the process), not just `docker compose config` | -| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) | +| Human SSO login — OIDC | **Enforced, verified live** — real login against a real Auth0 developer tenant, full browser round trip; correctly failed closed on an identity with no `tenant_memberships` row, then succeeded and issued a real session after `-grant-membership-*`, with `POST /internal/authorize` returning exactly the granted tenant/role | | Human SSO login — SAML | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) | -| Multi-tenant-membership login (tenant picker) | **Backend and frontend built and verified** (`GET /auth/memberships`, `POST /auth/select-tenant`, a pending-login token distinct from a real session; `web/src/routes/select-tenant` calls it via credentialed cross-origin fetch, genuinely exercised in a real browser) — not yet tried against a real running `enterprise-auth` container, still blocked on the same "no real external IdP" gap as OIDC/SAML above (there's no way to reach `/select-tenant` with a genuine pending-login cookie from the real binary without one) | +| Multi-tenant-membership login (tenant picker) | **Enforced, verified live** — a real Auth0 identity with two real tenant memberships (`acme` Admin, `globex` Viewer) landed on the real `/select-tenant` page against the real `enterprise-auth` container, rendered both with correct display names/roles via a real credentialed cross-origin `GET /auth/memberships`, and selecting either one issued a session that `POST /internal/authorize` confirmed matched — the selection genuinely determines the issued session's tenant, not just renders correctly. This pass also found and fixed a real bug: `web/Dockerfile` never declared `ARG`/`ENV` for `VITE_ALERTING_API_BASE_URL`/`VITE_ENTERPRISE_AUTH_BASE_URL`, so `docker-compose.yml`'s build args for them were silently dropped, leaving `enterpriseAuthBase` `undefined` in the built bundle | | Per-resource dashboard grants (`own/granted`) | **Enforced, verified live** — real Postgres integration tests for `dashboard_permissions` CRUD and the `PermissionStore` adapter all pass (only when `enterprise-api` serves traffic — plain `api` falls back to own/Admin only) | | Query audit logging (routine queries) | **Enforced**, fail-open, and now wired to a real writer via `enterprise-api` (`audit.QueryAPILogger`) | | Audit log tamper detection (hash chain) | **Enforced**, verified live |