Build and browser-verify the tenant-picker frontend page

web/src/routes/select-tenant now calls enterprise-auth's existing
GET /auth/memberships / POST /auth/select-tenant protocol (built earlier
this phase, previously called only from Go tests) via
fetch(..., {credentials: 'include'}) -- new listMemberships/selectTenant
functions in $lib/api.ts, using a dedicated request helper that reads
plain-text error bodies (loginhandler's http.Error responses), unlike
every other request helper in that file which expects JSON.

Credentialed cross-origin fetch needed CORS enterprise-auth didn't have:
api/httpserver.WithCORS's wildcard-friendly default can't be combined
with a credentialed request at all (browsers refuse to honor
Access-Control-Allow-Origin: "*" on one) -- added WithCredentialedCORS
(literal origin, Access-Control-Allow-Credentials: true) alongside it,
wired into enterprise-auth via a new CORS_ALLOWED_ORIGIN config var
defaulting to POST_LOGIN_REDIRECT_URL (web's own origin, the same
default pattern SELECT_TENANT_REDIRECT_URL already used).

adapter-static's route crawler doesn't discover a page nothing links to
(this one is only ever reached via enterprise-auth's redirect) -- fixed
with select-tenant/+page.ts's `export const prerender = true`, the same
declaration every other route already has.

Genuinely verified in a real browser in this environment, not just
type-checked: a throwaway Node server standing in for enterprise-auth's
exact wire contract (including its plain-text error bodies), driven
through the full flow via mcp__claude-in-chrome -- cross-origin
pending-login cookie set, credentialed preflight + GET/POST round trip,
a real click choosing a tenant, the post-selection redirect, and the
missing/expired-cookie error path rendering the backend's actual
message. No Docker or live Postgres/IdP needed, since the point was
exercising web's own fetch/CORS/cookie wiring, not enterprise-auth's
internals (already covered by loginhandler's own tests).

This closes the tenant-picker as the last named gap in Phase 4. What's
left is the already-disclosed live-verification caveat shared by every
Postgres/ClickHouse-backed piece and both SSO protocols: none of this
has run against a real database, external IdP, or multi-container
deployment in this environment.
This commit is contained in:
2026-08-14 23:02:09 -07:00
parent bdd42e06f6
commit abeee0076b
15 changed files with 513 additions and 85 deletions
+33 -27
View File
@@ -139,10 +139,10 @@ Postgres-backed pieces.
## Tenant selection (multi-membership identities)
The backend protocol for choosing a tenant is built and tested; the
frontend page that would actually call it is deliberately not (see
"Deliberately deferred" below). When `resolveIdentity` finds more than
one `tenant_memberships` row for a logged-in identity, `finishLogin`
Both the backend protocol for choosing a tenant and the frontend page
that calls it (`web/src/routes/select-tenant`) are now built. When
`resolveIdentity` finds more than one `tenant_memberships` row for a
logged-in identity, `finishLogin`
issues a `session.Manager.IssuePendingLogin` token (a distinct Go/JWT
type from a real session -- see that type's doc comment for a real bug
this design caught in its own tests: a shared JSON key would have let a
@@ -171,29 +171,34 @@ pending cookie, a `tenant_id` outside the identity's actual
memberships, a real session token rejected when presented as a pending
login).
**Deliberately deferred, not half-built** -- named explicitly rather than
silently left out:
- **The actual tenant-picker page** -- nothing in `web/` calls the
endpoints above yet. Building it is genuinely different, larger scope
than the backend protocol: `web` has zero session/cookie-handling
code today (confirmed by reading it end to end while designing this),
so a real picker page means adding that from scratch, plus CORS
wiring (`enterprise-auth` has no CORS middleware at all right now --
a cross-origin `fetch` with credentials from `web`'s origin needs
it), neither of which is verifiable in this environment without a
live backend and a browser session to exercise.
The frontend side needed two things `web` didn't have: session/cookie-
aware requests (`$lib/api.ts`'s `listMemberships`/`selectTenant`, both
`fetch(..., {credentials: 'include'})`) and CORS that actually allows a
credentialed cross-origin request -- `httpserver.WithCredentialedCORS`
(new, in `api/httpserver`, next to the plain `WithCORS` every other
service in this repo uses), wired in by this binary's `main.go` and
configured via the new `CORSAllowedOrigin` field
(`CORS_ALLOWED_ORIGIN`, defaulting to `PostLoginRedirectURL` --
`web`'s own origin is exactly what needs credentialed access here).
Browsers categorically refuse to honor `Access-Control-Allow-Origin: "*"`
on a credentialed request, which is why this couldn't reuse plain
`WithCORS`'s wildcard-friendly default the way `enterprise-api` does.
Genuinely verified in a real browser in this environment (see
`/web/README.md`'s "Tenant picker" section for exactly how, since no
live Postgres/IdP was needed to exercise `web`'s own fetch/CORS/cookie
wiring): the full cross-origin cookie round trip, a real click choosing
a tenant, and the post-selection redirect, plus the missing/expired
pending-login error path.
Ingest write-routing (both ClickHouse and Tantivy) is no longer on this
list -- see "Ingest write-routing (ClickHouse)" below and
`/search/README.md`'s "Per-tenant indices" section for Tantivy, which
needed no code in this module at all: `search`'s `IndexRegistry` already
lived in AGPL core, so its write side didn't need an `enterprise/`
counterpart the way ClickHouse's did.
Deployment-topology routing (does traffic actually reach `enterprise-api`
instead of `api`) is no longer deferred -- both `deploy/helm/sentry` and
`docker-compose.yml` make it a single-flag choice now (`enterprise.
enabled` / `COMPOSE_PROFILES`), see CLAUDE.md.
Two other things once named as deferred here are built too: ingest
write-routing, both ClickHouse (see "Ingest write-routing (ClickHouse)"
below) and Tantivy (`/search/README.md`'s "Per-tenant indices" section
-- needed no code in this module at all, since `search`'s
`IndexRegistry` already lived in AGPL core); and deployment-topology
routing (does traffic actually reach `enterprise-api` instead of
`api`), now a single-flag choice in both `deploy/helm/sentry` and
`docker-compose.yml` (`enterprise.enabled` / `COMPOSE_PROFILES`), see
CLAUDE.md.
## Ingest tenant identity
@@ -480,7 +485,8 @@ to, see `tenantprovision.ProvisionClickHouse`'s doc comment).
| `SAML_IDP_METADATA_URL` | (empty — SAML disabled if unset; if set, fetched and parsed at startup via `samlsp.FetchMetadata`, same trust level as `OIDC_ISSUER_URL`'s discovery fetch) |
| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes |
| `POST_LOGIN_REDIRECT_URL` | `http://localhost:3000` — where the browser lands after `internal/loginhandler` sets a session cookie |
| `SELECT_TENANT_REDIRECT_URL` | `{POST_LOGIN_REDIRECT_URL}/select-tenant` — where the browser lands for a multi-membership identity instead; nothing serves this route yet, see "Tenant selection" above |
| `SELECT_TENANT_REDIRECT_URL` | `{POST_LOGIN_REDIRECT_URL}/select-tenant` — where the browser lands for a multi-membership identity instead; `web/src/routes/select-tenant` serves it, see "Tenant selection" above |
| `CORS_ALLOWED_ORIGIN` | `{POST_LOGIN_REDIRECT_URL}` — must be a literal origin, not `*` (unlike `enterprise-api`'s var of the same name below): `GET /auth/memberships`/`POST /auth/select-tenant` are credentialed requests, and browsers refuse to honor a wildcard `Access-Control-Allow-Origin` on those |
## Environment variables (`enterprise-api`)
+9 -1
View File
@@ -37,6 +37,7 @@ import (
"github.com/crewjam/saml/samlsp"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/enterprise/internal/authhandler"
"github.com/sentry/sentry/enterprise/internal/config"
"github.com/sentry/sentry/enterprise/internal/loginhandler"
@@ -202,7 +203,14 @@ func main() {
authhandler.New(logger, sessionManager, features, rbac).RegisterRoutes(mux)
loginhandler.New(logger, oidcProvider, samlProvider, sessionManager, rbac, cfg.PostLoginRedirectURL, cfg.SelectTenantRedirectURL).RegisterRoutes(mux)
srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: mux}
// Credentialed, not plain, CORS: GET /auth/memberships and POST
// /auth/select-tenant are called from web's tenant-picker page via
// `fetch(..., {credentials: 'include'})` so the pending-login/session
// cookies actually go along -- see httpserver.WithCredentialedCORS's
// doc comment for why that rules out the wildcard origin every other
// service's WithCORS defaults to.
handler := httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin)
srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: handler}
errCh := make(chan error, 1)
go func() {
+18 -7
View File
@@ -20,13 +20,21 @@ type Config struct {
// 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.
// browser here. web/src/routes/select-tenant is the page that serves
// it (see that route's own comments) -- it calls GET
// /auth/memberships and POST /auth/select-tenant with
// `credentials: 'include'`, which is why CORSAllowedOrigin below has
// to be a literal origin, not WithCORS's zero-config "*" default.
SelectTenantRedirectURL string
// CORSAllowedOrigin is passed to httpserver.WithCredentialedCORS, not
// the plain httpserver.WithCORS every other service in this repo
// uses -- GET /auth/memberships / POST /auth/select-tenant are
// cookie-carrying requests (the pending-login cookie, then the real
// session cookie), and browsers categorically refuse to combine a
// credentialed fetch with an Access-Control-Allow-Origin: "*"
// response, so this can't default to the wildcard the way
// api/internal/config.CORSAllowedOrigin does.
CORSAllowedOrigin string
}
type PostgresConfig struct {
@@ -84,8 +92,11 @@ func Load() (Config, error) {
// 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.
// variable. CORSAllowedOrigin defaults the same way: web's own
// origin is exactly what needs credentialed cross-origin access to
// this service.
cfg.SelectTenantRedirectURL = getenv("SELECT_TENANT_REDIRECT_URL", cfg.PostLoginRedirectURL+"/select-tenant")
cfg.CORSAllowedOrigin = getenv("CORS_ALLOWED_ORIGIN", cfg.PostLoginRedirectURL)
// Required, unlike OIDC/SAML above: every enterprise-auth deployment
// issues and validates session/service tokens (internal/session),
@@ -17,10 +17,10 @@
// who they are, commits to no tenant yet) and redirects to
// selectTenantRedirectURL instead of issuing a session outright.
// GET /auth/memberships and POST /auth/select-tenant complete the round
// trip. The backend protocol is complete and independently testable via
// HTTP; the frontend page that would actually call it doesn't exist yet
// (a real tenant-picker UI is undesigned, separately-scoped frontend
// work -- see config.SelectTenantRedirectURL's doc comment).
// trip. web/src/routes/select-tenant is the frontend page that calls
// them, over credentialed cross-origin fetch (see
// httpserver.WithCredentialedCORS and config.CORSAllowedOrigin) -- see
// that route's own comments for the page itself.
package loginhandler
import (