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:
+40
-2
@@ -521,6 +521,39 @@ the full loop (does the operator's watch actually re-trigger a reconcile
|
||||
after `-provision-tenant`'s external status write the way controller-
|
||||
runtime's default predicate is expected to).
|
||||
|
||||
## 12. Tenant-picker backend protocol (no frontend yet, no Docker needed)
|
||||
|
||||
Like §9, this needs nothing but a local Go toolchain -- the real
|
||||
fake-IdP tests already exercise the full login → pending-login cookie →
|
||||
`GET /auth/memberships` → `POST /auth/select-tenant` → real session
|
||||
round trip:
|
||||
|
||||
```sh
|
||||
cd enterprise
|
||||
go test ./internal/session/... -run PendingLogin -v
|
||||
# real JWT signing/verification: issues a pending-login token, validates
|
||||
# it, and proves the two negative regressions that matter most --
|
||||
# a real session token must not validate as a pending login (they share
|
||||
# a signing key but PendingLoginClaims uses a disjoint json field name,
|
||||
# see that type's doc comment for the bug this caught in its own tests),
|
||||
# and a pending-login token must not validate as a real session either.
|
||||
|
||||
go test ./internal/loginhandler/... -run 'Memberships|SelectTenant|MultipleMemberships' -v
|
||||
# full round trip against the real fake OIDC/SAML IdPs: multi-membership
|
||||
# login sets a pending cookie and redirects (not a 501 anymore), GET
|
||||
# /auth/memberships lists the real tenant options with display names,
|
||||
# POST /auth/select-tenant re-derives role server-side and refuses a
|
||||
# tenant_id outside the identity's actual memberships.
|
||||
```
|
||||
|
||||
**Not built, and explicitly not attempted here**: the frontend page.
|
||||
`web` has no session/cookie-handling code anywhere in it today (checked
|
||||
while designing this), and `enterprise-auth` has no CORS middleware at
|
||||
all -- a cross-origin `fetch` with credentials from `web`'s origin to
|
||||
`enterprise-auth`'s would need it, and doesn't work today. Building the
|
||||
actual picker UI is real, separately-scoped frontend work; this section
|
||||
only closes the backend half.
|
||||
|
||||
## Known gaps (do not treat this phase as done without reading these)
|
||||
|
||||
Full accounting: `/docs/security/threat-model.md`. Headline items:
|
||||
@@ -553,8 +586,13 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
|
||||
- **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.
|
||||
`enterprise-auth` container. **The tenant-picker backend protocol is
|
||||
now built too** (§12) -- `GET /auth/memberships`/
|
||||
`POST /auth/select-tenant`, backed by a short-lived pending-login
|
||||
token distinct from a real session -- but nothing in `web` calls it
|
||||
yet, so a multi-membership identity still can't actually finish
|
||||
logging in through a browser today, just through direct HTTP calls
|
||||
(which is what §12's verification does).
|
||||
- No admin UI to create a `tenant_memberships` row, but §3a/§3b's manual
|
||||
SQL bootstrap is gone -- `enterprise-auth -create-tenant`/
|
||||
`-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant`
|
||||
|
||||
Reference in New Issue
Block a user