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).
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
// Package config loads enterprise-auth's configuration from environment
|
|
// variables, same convention as every other Go service in this repo.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
type Config struct {
|
|
HTTPListenAddr string
|
|
Postgres PostgresConfig
|
|
OIDC OIDCConfig
|
|
SAML SAMLConfig
|
|
SessionSigningKey []byte
|
|
// PostLoginRedirectURL is where the browser lands after
|
|
// internal/loginhandler sets a session cookie -- web's base URL in
|
|
// a real deployment.
|
|
PostLoginRedirectURL string
|
|
// SelectTenantRedirectURL is where the browser lands after a login
|
|
// resolves to more than one tenant_memberships row --
|
|
// internal/loginhandler issues a pending-login cookie and sends the
|
|
// browser here instead of straight to PostLoginRedirectURL. Nothing
|
|
// serves this route yet (a real tenant-picker page is undesigned
|
|
// frontend work -- see internal/loginhandler's package doc comment);
|
|
// the backend protocol (GET /auth/memberships, POST
|
|
// /auth/select-tenant) is complete and independently testable via
|
|
// HTTP regardless of what, if anything, is listening here today.
|
|
SelectTenantRedirectURL string
|
|
}
|
|
|
|
type PostgresConfig struct {
|
|
Addr string
|
|
Database string
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
// OIDCConfig is optional -- a deployment might configure OIDC, SAML,
|
|
// both, or (during early rollout) neither yet. Load() doesn't fail if
|
|
// these are unset; internal/oidc.New is only called once IssuerURL is
|
|
// actually present.
|
|
type OIDCConfig struct {
|
|
IssuerURL string
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURL string
|
|
}
|
|
|
|
// SAMLConfig is likewise optional. cmd/enterprise-auth/main.go fetches
|
|
// and parses IDPMetadataURL into the *saml.EntityDescriptor
|
|
// internal/saml.New requires at startup (crewjam/saml's
|
|
// samlsp.FetchMetadata) -- this struct just carries the raw config
|
|
// values this package's job (env-var loading) is scoped to.
|
|
type SAMLConfig struct {
|
|
EntityID string
|
|
ACSURL string
|
|
IDPMetadataURL string
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
cfg := Config{
|
|
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"),
|
|
Postgres: PostgresConfig{
|
|
Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
|
|
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"),
|
|
Username: getenv("POSTGRES_USERNAME", "sentry"),
|
|
Password: getenv("POSTGRES_PASSWORD", ""),
|
|
},
|
|
OIDC: OIDCConfig{
|
|
IssuerURL: getenv("OIDC_ISSUER_URL", ""),
|
|
ClientID: getenv("OIDC_CLIENT_ID", ""),
|
|
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
|
|
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
|
|
},
|
|
SAML: SAMLConfig{
|
|
EntityID: getenv("SAML_ENTITY_ID", ""),
|
|
ACSURL: getenv("SAML_ACS_URL", ""),
|
|
IDPMetadataURL: getenv("SAML_IDP_METADATA_URL", ""),
|
|
},
|
|
PostLoginRedirectURL: getenv("POST_LOGIN_REDIRECT_URL", "http://localhost:3000"),
|
|
}
|
|
// Defaults relative to PostLoginRedirectURL if not set explicitly --
|
|
// computed after cfg.PostLoginRedirectURL above so a caller
|
|
// overriding just POST_LOGIN_REDIRECT_URL still gets a sensible
|
|
// SelectTenantRedirectURL without also having to set the new
|
|
// variable.
|
|
cfg.SelectTenantRedirectURL = getenv("SELECT_TENANT_REDIRECT_URL", cfg.PostLoginRedirectURL+"/select-tenant")
|
|
|
|
// Required, unlike OIDC/SAML above: every enterprise-auth deployment
|
|
// issues and validates session/service tokens (internal/session),
|
|
// even one that hasn't configured any IdP yet. 32 bytes matches
|
|
// internal/session.MinSigningKeyBytes -- not imported here to avoid
|
|
// a config->session dependency for one constant, but the two values
|
|
// must be kept in sync.
|
|
signingKey := getenv("ENTERPRISE_SESSION_SIGNING_KEY", "")
|
|
if len(signingKey) < 32 {
|
|
return Config{}, fmt.Errorf("ENTERPRISE_SESSION_SIGNING_KEY must be set to at least 32 bytes (got %d)", len(signingKey))
|
|
}
|
|
cfg.SessionSigningKey = []byte(signingKey)
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func getenv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|