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:
+50
-6
@@ -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`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user