Build SAML login (enterprise/internal/loginhandler), mirroring OIDC

Adds GET /auth/saml/login + POST /auth/saml/acs alongside the existing
OIDC pair, both converging on the same upsert-user/resolve-tenant/
issue-session path. loginhandler.New now takes an optional
*saml.ServiceProvider, RegisterRoutes registers each protocol's routes
independently so either, both, or neither can be configured. SAML's
replay/unsolicited-response defense (InResponseTo, standing in for
OIDC's state) is carried via a SameSite=None sentry_saml_request cookie
-- None because the ACS endpoint receives a cross-site POST from the
IdP's origin, which SameSite=Lax cookies are never sent on.
enterprise-auth's main.go now fetches+parses SAML_IDP_METADATA_URL at
startup (samlsp.FetchMetadata) and wires the result through.

Verified to the same bar as OIDC: a real fake IdP
(crewjam/saml/samlidp, genuine XML signing/verification) drives the
full login->ACS->session-cookie round trip and negative paths (bad
InResponseTo, missing request cookie, missing email/NameID, no/multiple
tenant memberships), all in loginhandler/saml_test.go, no Docker
needed. The login-form HTML is bypassed by pre-seeding a saml.Session
directly into samlidp's session store and presenting the matching
`session` cookie -- an IdP-supported shortcut (confirmed by reading
GetSession), the same "skip the UI, keep the crypto real" approach
oidctest gave the OIDC tests.

Writing that test caught two real bugs in internal/saml.ParseResponse,
both fixed here: it never called r.ParseForm() before reading the
POSTed SAMLResponse field, so every real ACS POST would have silently
decoded an empty response; and its email-attribute matching missed
urn:oid:0.9.2342.19200300.100.1.3 (the standard LDAP "mail" OID), which
is what an IdP sends by default absent an explicit
AttributeConsumingService request for "email" -- exactly what
samlidp's own DefaultAssertionMaker does, and plausibly what real IdPs'
default SAML app templates do too.

Docs (CLAUDE.md, threat-model.md, architecture.md, enterprise/README.md,
phase-4-runbook.md, docker-compose.yml's enterprise-auth comment)
updated in lockstep: SAML login moves from "protocol mechanics only" to
"built, verified with a real fake IdP, not yet tried against a real
external IdP or a running enterprise-auth container" -- the same
disclosed gap OIDC already carried.
This commit is contained in:
2026-08-14 06:39:36 -07:00
parent 3037b31b0f
commit 08a90a27aa
14 changed files with 825 additions and 149 deletions
+62 -10
View File
@@ -24,6 +24,20 @@ of what was already run and passed. Two genuine exceptions:
all, so this one was actually run in this runbook's own session, not
just an earlier one. What's still unverified is wiring it into a real
running `enterprise-auth` container against a real external IdP.
- `enterprise/internal/loginhandler`'s full SAML login flow (§3b) --
same bar as OIDC above, verified against a real fake SAML IdP
(`crewjam/saml/samlidp`: genuine XML signing and signature
verification, a real `AuthnRequest`/`Response` round trip), no Docker
needed. Writing this test caught two real bugs in
`enterprise/internal/saml`, now fixed: `ParseResponse` never called
`r.ParseForm()` before reading the POSTed `SAMLResponse` field (every
real ACS POST would have silently decoded to nothing), and the email-
attribute matching didn't recognize `urn:oid:0.9.2342.19200300.100.1.3`
(the standard LDAP "mail" OID), which is what an IdP sends by default
when the SP doesn't explicitly request an attribute literally named
"email" -- crewjam's own fake IdP hit this path. Same remaining gap as
OIDC: not yet tried against a real external IdP or a running
`enterprise-auth` container.
Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement
walkthrough, the dashboards tenant-scoping fix, the Helm chart, the
@@ -145,6 +159,44 @@ to create the first tenant membership) is exactly the kind of rough
edge an admin UI would smooth over -- named as real future work, not
hidden.
## 3b. `enterprise-auth`: human login via SAML (new -- same "verified
live in this session, not against a real running container or a real
external IdP" caveat as §3a)
`enterprise/internal/loginhandler`'s SAML tests already prove the
mechanism works end to end against a real fake SAML IdP (`go test
./internal/loginhandler/... -run SAML -v` from `enterprise/`, no Docker
needed). 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 SAML IdP (many identity providers
offer a free developer/trial tenant with SAML app support):
```sh
# Add to enterprise-auth's environment in docker-compose.yml (or a
# docker-compose.override.yml):
# SAML_ENTITY_ID: "http://localhost:8082/saml/metadata"
# SAML_ACS_URL: "http://localhost:8082/auth/saml/acs"
# SAML_IDP_METADATA_URL: "https://your-idp.example.com/metadata"
# Register SAML_ENTITY_ID/SAML_ACS_URL with the IdP's application config
# -- the IdP needs Sentry's ACS URL to know where to POST the assertion.
docker compose up -d --build enterprise-auth
curl -s http://localhost:8082/auth/features
# expect: {"sso_configured":true,"oidc_enabled":false,"saml_enabled":true}
```
Bootstrapping the first `tenant_memberships` row is the same manual-SQL
dance as §3a (log in once, it fails with 403, insert the membership
using the `users` row that got created, log in again). Then visit
`http://localhost:8082/auth/saml/login` in a real browser, complete the
IdP's login, and confirm a `sentry_session` cookie lands after redirect
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
Without touching the main stack's `api` container (so step 2's baseline
@@ -164,11 +216,11 @@ docker stop sentry-api-enforced
```
`GET /dashboards` on the same enforced instance should return 401
without a token — there's no way to mint a human (Viewer/Editor/etc.)
session yet (no OIDC/SAML login handler exists — see
`enterprise/cmd/enterprise-auth/main.go`'s doc comment), so this
runbook can't walk through a real human RBAC scenario end to end. That
gap is real, not an oversight in this runbook.
without a token — this section only demonstrates the service-token path
(§3/§3a/§3b cover minting a real human session via OIDC or SAML); walking
that session cookie through this same enforced instance to get a 200 is
left as the natural next verification step once real Docker/K8s access
exists, not yet done in this runbook.
## 5. Dashboards tenant scoping
@@ -363,11 +415,11 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
and the one shared Tantivy index no matter what. A newly-provisioned
tenant's storage is real, isolated at query time, and permanently
empty until this changes — undesigned, not just unbuilt.
- **Human SSO login now works for OIDC** (§3a) -- verified with a real
fake IdP, not yet a real external one or a running `enterprise-auth`
container. **SAML login still doesn't exist** -- protocol wiring only,
no ACS handler. No tenant-picker UI for a multi-membership identity
either (refused outright).
- **Human SSO login now works for both OIDC (§3a) and SAML (§3b)** --
each verified with a real fake IdP (genuine cryptographic signing and
verification), not yet a real external IdP or a running
`enterprise-auth` container. No tenant-picker UI for a multi-membership
identity either (refused outright) for either protocol.
- No admin UI to create a `tenant_memberships` row -- §3a's manual SQL
bootstrap is the only way to grant a logged-in identity access today.
- **No per-resource dashboard grants** (`dashboard_permissions` has a