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:
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user