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
@@ -360,6 +360,46 @@ func (s *Store) ListMembershipsForUser(ctx context.Context, userID string) ([]Me
return out, rows.Err()
}
// MembershipWithTenant is ListMembershipsWithTenantForUser's result row
// -- Membership plus the tenant's display name, the shape a
// tenant-picker UI needs (a bare tenant_id/Role isn't enough to show a
// human something recognizable to choose between).
type MembershipWithTenant struct {
TenantID string
TenantDisplayName string
Role Role
}
// ListMembershipsWithTenantForUser is ListMembershipsForUser plus a join
// against tenants -- used by loginhandler's multi-membership
// tenant-selection step (GET /auth/memberships), which is the one
// caller that actually needs to show a human "here are your tenants,"
// not just resolve a single membership programmatically.
func (s *Store) ListMembershipsWithTenantForUser(ctx context.Context, userID string) ([]MembershipWithTenant, error) {
rows, err := s.pool.Query(ctx, `
SELECT m.tenant_id, t.display_name, m.role
FROM tenant_memberships m
JOIN tenants t ON t.id = m.tenant_id
WHERE m.user_id = $1
ORDER BY t.display_name`, userID)
if err != nil {
return nil, fmt.Errorf("rbacstore: listing memberships with tenant: %w", err)
}
defer rows.Close()
var out []MembershipWithTenant
for rows.Next() {
var m MembershipWithTenant
var role string
if err := rows.Scan(&m.TenantID, &m.TenantDisplayName, &role); err != nil {
return nil, fmt.Errorf("rbacstore: scanning membership with tenant: %w", err)
}
m.Role = Role(role)
out = append(out, m)
}
return out, rows.Err()
}
// DataSource is one tenant's data-plane location -- today, exactly one
// ClickHouse database + one Tantivy index per tenant (see
// /docs/phase-4-rbac-design.md's "data_sources" extension-point