Document real Auth0 SAML verification, closing §3b -- the last SSO gap

Auth0's SAML2 Web App addon (the same dev tenant §3a used) stood in as
a real SAML IdP, over a genuine self-signed TLS proxy in front of
enterprise-auth (required, not optional, for SAML's SameSite=None
cookie). Full round trip confirmed: real signed assertion, audience/
destination/signature validation, correct multi-membership handling,
and POST /internal/authorize returning the selected tenant/role.
Updates the runbook's verification status, §3b, and the threat model's
"Read this first" finding and summary table to reflect this and the
isSecureRequest fix it found. §7/§11's live-cluster steps (no
kind/kubectl in this environment) are now the only remaining gap in the
entire runbook.
This commit is contained in:
2026-08-15 18:08:25 -07:00
parent f5ca09f686
commit 783d66786b
2 changed files with 96 additions and 56 deletions
+82 -42
View File
@@ -60,29 +60,42 @@ Docker-free testing could have caught:
`/docs/security/threat-model.md`'s "Read this first" section for the `/docs/security/threat-model.md`'s "Read this first" section for the
full account. full account.
**What's still not verified, and why**: §3a (OIDC) and §12 (tenant **What's still not verified, and why**: §3a (OIDC), §3b (SAML), and §12
picker, both single- and multi-membership paths) are now closed -- (tenant picker, both single- and multi-membership paths) are now all
verified against a real Auth0 developer tenant, full browser round closed -- verified against a real Auth0 developer tenant acting as both
trips, real session cookies authorizing correctly, including selecting an OIDC and a SAML IdP, full browser round trips, real session cookies
between two real tenant memberships and getting back the right authorizing correctly, including selecting between two real tenant
tenant/role each time (see §3a and §12 below). That pass also found and memberships and getting back the right tenant/role each time (see §3a,
fixed a real bug: `web/Dockerfile` only declared `ARG`/`ENV` for §3b, and §12 below). That pass found and fixed two real bugs:
`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 1. `web/Dockerfile` only declared `ARG`/`ENV` for `VITE_API_BASE_URL`,
this environment doesn't have credentials for -- the fake-IdP test (real so `docker-compose.yml`'s build args for
XML signing/verification, no stand-in shortcuts) is the strongest `VITE_ALERTING_API_BASE_URL`/`VITE_ENTERPRISE_AUTH_BASE_URL` were
evidence available without one. §7 and §11's live-cluster steps need silently dropped by Docker (an undeclared `--build-arg` is dropped,
`kind`/`kubectl`, which aren't installed in this environment -- their not an error) -- `enterpriseAuthBase` came out `undefined` in the
offline-only checks (`go build`/`go vet`/`go test`, `helm lint`, `helm built bundle, so the tenant-picker page threw "enterprise-auth is not
template` + parsing the rendered YAML) all pass and are documented as configured" against a real running container even though
such below. `docker-compose.yml` looked correct.
2. `enterprise/internal/loginhandler.go` decided every cookie's `Secure`
attribute from `r.TLS != nil` alone -- wrong for the deployment shape
this handler actually runs in, since `enterprise-auth` never
terminates TLS itself. Closing SAML's real-IdP gap required a genuine
TLS-terminating proxy in front of it (SAML's request cookie is
`SameSite=None`, which the cookie spec requires to be paired with
`Secure`, and Chrome silently drops it otherwise), which is exactly
what surfaced this: `r.TLS` was nil at the Go process even over a
real HTTPS client connection, so the cookie came back non-`Secure`
and Chrome dropped it, breaking the flow. Fixed with an
`isSecureRequest(r)` helper that also honors
`X-Forwarded-Proto: https` -- affects every cookie this handler sets,
not just SAML's, so this would have hit any real reverse-proxied
deployment, not just this test.
§7 and §11's live-cluster steps still 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. That's the
only remaining gap in this entire runbook that isn't already closed.
If you're reading this to decide whether Phase 4 is production-ready: If you're reading this to decide whether Phase 4 is production-ready:
closer than before, but not yet -- see closer than before, but not yet -- see
@@ -210,44 +223,71 @@ the one thing here still only reachable via the HTTP endpoints
directly, no operator flag, since `sentryctl dashboards permissions directly, no operator flag, since `sentryctl dashboards permissions
list|grant|revoke` already exists as that surface instead (§5a). list|grant|revoke` already exists as that surface instead (§5a).
## 3b. `enterprise-auth`: human login via SAML (new -- same "verified ## 3b. `enterprise-auth`: human login via SAML (now genuinely verified
live in this session, not against a real running container or a real against a real external IdP, over real HTTPS)
external IdP" caveat as §3a)
`enterprise/internal/loginhandler`'s SAML tests already prove the `enterprise/internal/loginhandler`'s SAML tests already prove the
mechanism works end to end against a real fake SAML IdP (`go test mechanism works end to end against a real fake SAML IdP (`go test
./internal/loginhandler/... -run SAML -v` from `enterprise/`, no Docker ./internal/loginhandler/... -run SAML -v` from `enterprise/`, no Docker
needed). What's still unverified is wiring it into this actual running needed). **Closed for real in this pass**, using the same Auth0
stack. To try that for real, point `docker-compose.yml`'s developer tenant §3a used, via that app's **SAML2 Web App addon**
`enterprise-auth` service at a real SAML IdP (many identity providers (Auth0 acting as a real SAML IdP, not just OIDC). This is where SAML's
`SameSite=None` request cookie (which the cookie spec requires to be
paired with `Secure`) made the plain-HTTP setup this environment
otherwise defaults to a hard blocker -- getting a real assertion back
required standing up a genuine (self-signed, dev-only) TLS-terminating
nginx proxy in front of `enterprise-auth`. Doing that surfaced a real,
previously-undiscovered bug: every cookie `loginhandler.go` sets decided
`Secure` from `r.TLS != nil` alone, which is wrong for the deployment
shape this handler actually runs in -- `enterprise-auth` never
terminates TLS itself, so in any real deployment (behind an ingress/load
balancer, exactly what the proxy here stands in for) `r.TLS` is nil even
over a genuinely HTTPS client connection. The `Secure` attribute was
silently missing behind the proxy, and Chrome dropped the cookie outright
-- fixed with a new `isSecureRequest(r)` helper that also honors
`X-Forwarded-Proto: https`, with its own regression test
(`TestHandleSAMLLoginSetsSecureCookieBehindATLSProxy`). This bug affects
every cookie this handler sets, not just SAML's, and would have hit
production the same way, so this generalizes well beyond closing this
one section.
With that fixed, the full flow completed for real: login redirected to
Auth0's real SAML SSO endpoint, Auth0 posted back a real signed
assertion, `enterprise/internal/saml` validated it (audience, destination,
signature) and extracted the email attribute, and the identity landed on
the real `/select-tenant` page with both real tenant memberships,
exactly like §12's OIDC walkthrough -- confirmed via
`POST /internal/authorize` returning the selected tenant/role.
To try this yourself, point `docker-compose.yml`'s `enterprise-auth`
service at a real SAML IdP (many identity providers, including Auth0,
offer a free developer/trial tenant with SAML app support): offer a free developer/trial tenant with SAML app support):
```sh ```sh
# Add to enterprise-auth's environment in docker-compose.yml (or a # Add to enterprise-auth's environment in docker-compose.yml (or a
# docker-compose.override.yml): # docker-compose.override.yml):
# SAML_ENTITY_ID: "http://localhost:8082/saml/metadata" # SAML_ENTITY_ID: "https://<your-https-host>/saml/metadata"
# SAML_ACS_URL: "http://localhost:8082/auth/saml/acs" # SAML_ACS_URL: "https://<your-https-host>/auth/saml/acs"
# SAML_IDP_METADATA_URL: "https://your-idp.example.com/metadata" # SAML_IDP_METADATA_URL: "https://your-idp.example.com/metadata"
# Register SAML_ENTITY_ID/SAML_ACS_URL with the IdP's application config # Register SAML_ENTITY_ID (as "audience")/SAML_ACS_URL (as the
# -- the IdP needs Sentry's ACS URL to know where to POST the assertion. # "Application Callback URL") with the IdP's application config -- the
# IdP needs Sentry's ACS URL to know where to POST the assertion, and
# the two must actually agree or the SP-side validation fails with a
# generic "Authentication failed" (found the hard way -- crewjam/saml's
# error here doesn't distinguish audience mismatch from other causes).
# <your-https-host> must be real HTTPS, not plain HTTP -- see above.
docker compose up -d --build enterprise-auth docker compose up -d --build enterprise-auth
curl -s http://localhost:8082/auth/features curl -s https://<your-https-host>/auth/features
# expect: {"sso_configured":true,"oidc_enabled":false,"saml_enabled":true} # expect: {"sso_configured":true,"oidc_enabled":false,"saml_enabled":true}
``` ```
Bootstrapping the first `tenant_memberships` row uses the same Bootstrapping the first `tenant_memberships` row uses the same
`-create-tenant`/`-grant-membership-*` flags as §3a (log in once, it `-create-tenant`/`-grant-membership-*` flags as §3a (log in once, it
fails with 403, grant the membership using the email you logged in fails with 403, grant the membership using the email you logged in
with, log in again). Then visit with, log in again). Then visit `/auth/saml/login` on your HTTPS host in
`http://localhost:8082/auth/saml/login` in a real browser, complete the a real browser, complete the IdP's login, and confirm a `sentry_session`
IdP's login, and confirm a `sentry_session` cookie lands after redirect cookie lands after redirect to `POST_LOGIN_REDIRECT_URL`.
to `POST_LOGIN_REDIRECT_URL`. Note SAML's `sentry_saml_request` cookie
is `SameSite=None`, which requires `Secure` -- i.e. this only works over
HTTPS in a real deployment, unlike OIDC's redirect-based callback which
tolerates plain HTTP for local dev (see
`enterprise/internal/loginhandler/loginhandler.go`'s `handleSAMLLogin`
doc comment for why).
## 4. Turn on RBAC enforcement and prove it actually blocks/allows ## 4. Turn on RBAC enforcement and prove it actually blocks/allows
+14 -14
View File
@@ -52,20 +52,20 @@ per record would be a real throughput cost neither implementation
accepts), so a minute-wide staleness window remains on both sides by accepts), so a minute-wide staleness window remains on both sides by
design, not by oversight. What's left: two tenants (`acme`, `globex`) design, not by oversight. What's left: two tenants (`acme`, `globex`)
have been provisioned and exercised end-to-end in this environment. have been provisioned and exercised end-to-end in this environment.
Human login itself is now verified for real — a real Auth0 identity Human login itself is now verified for real, via both protocols — a
logged in via OIDC, selected between both tenants, and real Auth0 identity logged in via OIDC and (separately) via Auth0's
SAML2 Web App addon, selected between both tenants each way, and
`POST /internal/authorize` confirmed each selection issued the right `POST /internal/authorize` confirmed each selection issued the right
tenant/role (see §3a/§12 below) — but that walkthrough ran against plain tenant/role (see §3a/§3b/§12 below) — but that walkthrough ran against
`api` serving `web`'s traffic, not `enterprise-api`, so the specific plain `api` serving `web`'s traffic, not `enterprise-api`, so the
combination of "real human OIDC session" and "real per-tenant ClickHouse specific combination of "real human SSO session" and "real per-tenant
routing via `chrunner`" in the same request hasn't been driven end to ClickHouse routing via `chrunner`" in the same request hasn't been
end yet; each half is independently confirmed (real ClickHouse driven end to end yet; each half is independently confirmed (real
connections and Go integration tests for the routing half, real Auth0 ClickHouse connections and Go integration tests for the routing half,
sessions for the human-login half), just not together in one request. real Auth0 sessions for the human-login half), just not together in one
SAML's human-login half still needs a real external IdP with SAML app request. And **whether a given deployment actually runs the isolated
support (see §3b below). And **whether a given deployment actually runs binaries** remains a deployment-time decision, not a code-level
the isolated binaries** remains a deployment-time decision, not a guarantee — see below.
code-level guarantee — see below.
**ClickHouse (the SQL path) is built and now genuinely verified live.** **ClickHouse (the SQL path) is built and now genuinely verified live.**
`enterprise/internal/tenantprovision` (real `CREATE DATABASE`/`CREATE `enterprise/internal/tenantprovision` (real `CREATE DATABASE`/`CREATE
@@ -563,7 +563,7 @@ terms:
| 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` (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` | | 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 | **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 — 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) | | Human SSO login — SAML | **Enforced, verified live** — real login against Auth0's SAML2 Web App addon acting as a real SAML IdP, over real (self-signed, dev-only) TLS; a real signed assertion validated (audience, destination, signature), landed on `/select-tenant` with real memberships, and `POST /internal/authorize` confirmed the selected tenant/role. Found and fixed a real bug in the process: `loginhandler.go`'s cookies decided `Secure` from `r.TLS != nil` alone, which is wrong behind any TLS-terminating reverse proxy (the deployment shape this handler actually runs in) — `enterprise-auth` never terminates TLS itself, so `r.TLS` was nil even over a genuinely HTTPS connection, silently dropping `Secure` and breaking SAML's `SameSite=None` cookie |
| 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 | | 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) | | 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`) | | Query audit logging (routine queries) | **Enforced**, fail-open, and now wired to a real writer via `enterprise-api` (`audit.QueryAPILogger`) |