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
+40 -2
View File
@@ -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`
+18 -6
View File
@@ -176,11 +176,23 @@ container against a *real* external IdP (Google/Okta/etc.) — that needs
real IdP credentials and a reachable callback/ACS URL neither of which
this environment has; see `/docs/phase-4-runbook.md`.
A user with zero or more than one `tenant_memberships` row is refused
outright (403 / 501 respectively) rather than guessed at — a
tenant-selection UI for the multi-membership case is real, undesigned
future work, not silently approximated, for either protocol.
`GET /auth/features` (`enterprise/internal/authhandler`) reports whether
A user with zero `tenant_memberships` rows is refused outright (403).
More than one no longer guesses or refuses: `finishLogin` issues a
short-lived `session.Manager` "pending login" token (a distinct Go/JWT
type from a real session, with its own disjoint claim name so a real
session token can't double as one — a real bug this design's own test
suite caught before it shipped, see `session.PendingLoginClaims`'s doc
comment) and redirects to a not-yet-served URL instead, backed by two
new endpoints (`GET /auth/memberships`, `POST /auth/select-tenant`) that
list the identity's real tenant options and, on selection, re-derive the
role for the chosen tenant server-side (never trusting a client-supplied
role) before issuing the real session. This is the *backend protocol*
for tenant selection, verified with the same real-fake-IdP tests as the
rest of `internal/loginhandler` — the frontend page that would call it
doesn't exist (`web` has no session/cookie-handling code at all today,
and `enterprise-auth` has no CORS middleware for a cross-origin `fetch`
with credentials to work), both real, separately-scoped gaps, not
silently approximated. `GET /auth/features` (`enterprise/internal/authhandler`) reports whether
OIDC/SAML are *configured*, for `/web`'s settings page to conditionally
render — independent of whether a login button actually exists yet in
the UI (it doesn't; only the HTTP endpoints do).
@@ -413,7 +425,7 @@ terms:
| Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Enforced**`api`/`enterprise-api` are mutually exclusive via `COMPOSE_PROFILES`, same flag choice as Helm's `enterprise.enabled`; verified via `docker compose config`, not an actual `docker compose up` in this environment |
| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
| Human SSO login — SAML | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
| Multi-tenant-membership login (tenant picker) | **Not implemented** — refused with a clear error, not guessed |
| Multi-tenant-membership login (tenant picker) | **Backend protocol built and verified** (`GET /auth/memberships`, `POST /auth/select-tenant`, a pending-login token distinct from a real session) — no frontend page calls it yet |
| Per-resource dashboard grants (`own/granted`) | **Built, unit-tested against a fake store; live-Postgres integration tests written, not run in this environment** (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`) |
| Audit log tamper detection (hash chain) | **Enforced**, verified live |