Build the tenant-picker backend protocol (no frontend yet, by design)

A multi-membership identity (belongs to more than one tenant) used to
get a flat 501 refusal -- named as undesigned future work across
CLAUDE.md/threat-model.md/the runbook since early Phase 4. Scope for
this change was agreed via AskUserQuestion: backend protocol only,
fully verified via real HTTP round trips, not the actual picker page --
web has zero session/cookie-handling code today (confirmed while
researching this), so building that is separately-scoped, unverifiable
frontend work in this environment (no live backend, no browser).

session.Manager gains IssuePendingLogin/ValidatePendingLogin, a second
JWT token type proving identity without committing to a tenant yet
(10-minute TTL). PendingLoginClaims is deliberately a distinct Go type
from Claims, and -- caught by this change's own test suite before it
shipped -- needed a JSON field name disjoint from Claims.UserID's
"user_id" too: go-jose's unmarshal is happy to populate a struct from
any token whose claims happen to share a key, so a real session token
would otherwise have parsed successfully as a pending login. Fixed via
"pending_user_id" instead; both directions (session-as-pending,
pending-as-session) now have regression tests.

rbacstore.ListMembershipsWithTenantForUser joins tenant_memberships
with tenants, since a picker needs display names, not just IDs.

loginhandler.resolveIdentity's multiple-membership branch no longer
errors -- finishLogin routes it into startTenantSelection instead,
which issues a pending-login cookie (Path=/auth, so it's never sent on
ordinary requests) and redirects to a new configurable
SelectTenantRedirectURL (defaults to {POST_LOGIN_REDIRECT_URL}/select-
tenant). Two new routes complete the round trip: GET /auth/memberships
lists the pending identity's real tenant options, and POST
/auth/select-tenant re-derives the role for the chosen tenant
server-side (never trusts a client-supplied role, refuses a tenant_id
outside the identity's actual memberships with 403) before issuing the
real session -- responding with JSON {"redirect_url": ...}, not a
redirect, since a POST/fetch caller should control its own navigation.

Verified with the same real-fake-IdP tests the rest of this package
uses (coreos/go-oidc's oidctest, crewjam/saml's samlidp): the full
login -> pending cookie -> GET /auth/memberships -> POST
/auth/select-tenant -> real session round trip for both protocols, plus
negative paths (missing/expired pending cookie, a tenant_id outside
membership, a real session token rejected as a pending login and vice
versa). ErrMultipleMemberships is removed -- it's not an error path
anymore.

Docs updated in lockstep: CLAUDE.md, threat-model.md (including its
summary table), phase-4-runbook.md (new §12), enterprise/README.md
(new "Tenant selection" section, explicit about what's still not built
and why: no session handling in web, no CORS on enterprise-auth).
This commit is contained in:
2026-08-14 14:04:37 -07:00
parent cfcbc77507
commit d2c76aa3a4
12 changed files with 873 additions and 71 deletions
+50 -6
View File
@@ -55,9 +55,12 @@ section for exactly what "not yet run" means here and why. Don't read
-- the actual human login flow, previously entirely missing. Redirects
to the configured IdP with CSRF-protection state in a short-lived
cookie, exchanges the code, verifies the ID token via `internal/oidc`,
upserts a `users` row, resolves tenant/role from exactly one
`tenant_memberships` row (refuses with a clear error on zero or
multiple -- no tenant-picker UI yet), and issues a session cookie.
upserts a `users` row, resolves tenant/role from `tenant_memberships`
(refuses with a clear error on zero rows; more than one starts a real
tenant-selection round trip -- `GET /auth/memberships` +
`POST /auth/select-tenant`, backed by a short-lived
`session.Manager.IssuePendingLogin` token -- rather than guessing; see
"Tenant selection" below), and issues a session cookie.
**This one genuinely is verified**, unlike the ClickHouse pieces above:
`loginhandler_test.go` runs the full flow against a real fake IdP
(`coreos/go-oidc`'s own `oidctest` package, real RS256 signing and
@@ -134,11 +137,51 @@ manage-grants regression); real integration tests exist in
environment, same disclosed gap as the rest of this package's
Postgres-backed pieces.
## Tenant selection (multi-membership identities)
The backend protocol for choosing a tenant is built and tested; the
frontend page that would actually call it is deliberately not (see
"Deliberately deferred" below). When `resolveIdentity` finds more than
one `tenant_memberships` row for a logged-in identity, `finishLogin`
issues a `session.Manager.IssuePendingLogin` token (a distinct Go/JWT
type from a real session -- see that type's doc comment for a real bug
this design caught in its own tests: a shared JSON key would have let a
full session token double as a pending login) as a
`sentry_pending_login` cookie (`Path=/auth`) and redirects to
`SELECT_TENANT_REDIRECT_URL` (defaults to
`{POST_LOGIN_REDIRECT_URL}/select-tenant`) instead of completing the
login. From there:
- `GET /auth/memberships` -- lists the pending identity's tenants
(`tenant_id`, `tenant_display_name`, `role`) to choose between.
- `POST /auth/select-tenant` with `{"tenant_id": "..."}` -- re-derives
the role for that specific tenant server-side (never trusts a
client-supplied role, only checks the claimed `tenant_id` against the
identity's actual memberships, refusing with 403 otherwise), issues
the real session cookie, and responds with
`{"redirect_url": "..."}` for the caller to navigate to -- JSON, not a
redirect, since this is a POST/fetch call a frontend page should
control the navigation for itself.
Verified with the same real-fake-IdP tests as the rest of this package
(`loginhandler_test.go`/`saml_test.go`): the full login -> pending
cookie -> `GET /auth/memberships` -> `POST /auth/select-tenant` -> real
session round trip, plus negative paths (missing/expired/wrong-type
pending cookie, a `tenant_id` outside the identity's actual
memberships, a real session token rejected when presented as a pending
login).
**Deliberately deferred, not half-built** -- named explicitly rather than
silently left out:
- A tenant-picker UI/flow for an identity with more than one
`tenant_memberships` row -- `loginhandler` refuses these logins
outright rather than guessing (`ErrMultipleMemberships`).
- **The actual tenant-picker page** -- nothing in `web/` calls the
endpoints above yet. Building it is genuinely different, larger scope
than the backend protocol: `web` has zero session/cookie-handling
code today (confirmed by reading it end to end while designing this),
so a real picker page means adding that from scratch, plus CORS
wiring (`enterprise-auth` has no CORS middleware at all right now --
a cross-origin `fetch` with credentials from `web`'s origin needs
it), neither of which is verifiable in this environment without a
live backend and a browser session to exercise.
- **Ingest tenant-awareness, for either storage engine** -- `chrunner`/
`searchclient` prove read isolation given tenant-scoped data exists,
but nothing writes it: every record `ingest` produces still lands in
@@ -344,6 +387,7 @@ to, see `tenantprovision.ProvisionClickHouse`'s doc comment).
| `SAML_IDP_METADATA_URL` | (empty — SAML disabled if unset; if set, fetched and parsed at startup via `samlsp.FetchMetadata`, same trust level as `OIDC_ISSUER_URL`'s discovery fetch) |
| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes |
| `POST_LOGIN_REDIRECT_URL` | `http://localhost:3000` — where the browser lands after `internal/loginhandler` sets a session cookie |
| `SELECT_TENANT_REDIRECT_URL` | `{POST_LOGIN_REDIRECT_URL}/select-tenant` — where the browser lands for a multi-membership identity instead; nothing serves this route yet, see "Tenant selection" above |
## Environment variables (`enterprise-api`)