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:
@@ -239,7 +239,7 @@ func fullSAMLLoginFlow(t *testing.T, h *Handler, idp *testSAMLIdP, nameID, email
|
||||
|
||||
func TestHandleSAMLLoginRedirectsAndSetsRequestCookie(t *testing.T) {
|
||||
idp := newTestSAMLIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
@@ -274,7 +274,7 @@ func TestFullSAMLLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
|
||||
store := newFakeUserStore()
|
||||
store.memberships["user-saml-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-saml-user-1", Role: rbacstore.RoleEditor}}
|
||||
sessionManager := newTestSessionManager(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), sessionManager, store, "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), sessionManager, store, "http://web/", "http://web/select-tenant")
|
||||
|
||||
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-1", "[email protected]")
|
||||
|
||||
@@ -305,7 +305,7 @@ func TestFullSAMLLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
|
||||
|
||||
func TestFullSAMLLoginFlowRefusesNoMembership(t *testing.T) {
|
||||
idp := newTestSAMLIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
|
||||
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-2", "[email protected]")
|
||||
|
||||
@@ -314,25 +314,42 @@ func TestFullSAMLLoginFlowRefusesNoMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullSAMLLoginFlowRefusesMultipleMemberships(t *testing.T) {
|
||||
// TestFullSAMLLoginFlowStartsTenantSelectionForMultipleMemberships is
|
||||
// SAML's side of the tenant-picker regression test -- see
|
||||
// loginhandler_test.go's OIDC equivalent for the full reasoning; both
|
||||
// protocols converge on the same finishLogin/resolveIdentity, so the
|
||||
// behavior must match exactly.
|
||||
func TestFullSAMLLoginFlowStartsTenantSelectionForMultipleMemberships(t *testing.T) {
|
||||
idp := newTestSAMLIdP(t)
|
||||
store := newFakeUserStore()
|
||||
store.memberships["user-saml-user-3"] = []rbacstore.Membership{
|
||||
{TenantID: "acme", UserID: "user-saml-user-3", Role: rbacstore.RoleViewer},
|
||||
{TenantID: "globex", UserID: "user-saml-user-3", Role: rbacstore.RoleAdmin},
|
||||
}
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), store, "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), store, "http://web/", "http://web/select-tenant")
|
||||
|
||||
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-3", "[email protected]")
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501; body=%s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "http://web/select-tenant" {
|
||||
t.Fatalf("Location = %q, want http://web/select-tenant", loc)
|
||||
}
|
||||
var pendingCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == pendingLoginCookieName {
|
||||
pendingCookie = c
|
||||
}
|
||||
}
|
||||
if pendingCookie == nil || pendingCookie.Value == "" {
|
||||
t.Fatal("expected a non-empty pending-login cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullSAMLLoginFlowRefusesMissingEmail(t *testing.T) {
|
||||
idp := newTestSAMLIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
|
||||
rec := fullSAMLLoginFlow(t, h, idp, "saml-user-4", "") // no email attribute in the assertion
|
||||
|
||||
@@ -343,7 +360,7 @@ func TestFullSAMLLoginFlowRefusesMissingEmail(t *testing.T) {
|
||||
|
||||
func TestSAMLACSRejectsMissingRequestCookie(t *testing.T) {
|
||||
idp := newTestSAMLIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
@@ -363,7 +380,7 @@ func TestSAMLACSRejectsMissingRequestCookie(t *testing.T) {
|
||||
|
||||
func TestSAMLACSRejectsWrongRequestID(t *testing.T) {
|
||||
idp := newTestSAMLIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, idp.serviceProvider(t), newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
@@ -394,7 +411,7 @@ func TestSAMLACSRejectsWrongRequestID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterRoutesNoOpWhenSAMLNotConfigured(t *testing.T) {
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
@@ -411,7 +428,7 @@ func TestRegisterRoutesNoOpWhenSAMLNotConfigured(t *testing.T) {
|
||||
// typed-nil-interface trap this guards against.
|
||||
func TestRegisterRoutesNoOpWithTypedNilSAMLProviderVariable(t *testing.T) {
|
||||
var provider *samlpkg.ServiceProvider // stays nil -- main.go's shape when SAML_IDP_METADATA_URL is unset
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, provider, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, provider, newTestSessionManager(t), newFakeUserStore(), "http://web/", "http://web/select-tenant")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user