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
+29 -8
View File
@@ -222,13 +222,16 @@ its real result into the CRD — a real credential Secret, not the
previous placeholder that authenticated against nothing, and status previous placeholder that authenticated against nothing, and status
fields the reconciler derives `Phase`/`Ready` from instead of fields the reconciler derives `Phase`/`Ready` from instead of
independently guessing "Active" the moment a Tenant object exists. The independently guessing "Active" the moment a Tenant object exists. The
tenant-picker's backend protocol is built too: an identity with more tenant-picker is now fully built, backend and frontend: an identity with
than one `tenant_memberships` row now gets a real `GET more than one `tenant_memberships` row gets a real `GET
/auth/memberships`/`POST /auth/select-tenant` round trip (a short-lived /auth/memberships`/`POST /auth/select-tenant` round trip (a short-lived
pending-login token, distinct from a real session by both Go type and pending-login token, distinct from a real session by both Go type and
JWT claim name — a real token-confusion bug this design's own tests JWT claim name — a real token-confusion bug this design's own tests
caught before it shipped) instead of the flat refusal Phase 4 shipped caught before it shipped) instead of the flat refusal Phase 4 shipped
with earlier. Ingest tenant-awareness — the gap this section used to with earlier, and `web/src/routes/select-tenant` is the page that
actually calls it — see the Phase 4 exit-criteria paragraph below for
what changed to make that verifiable in this environment. Ingest
tenant-awareness — the gap this section used to
call "undesigned" — now has a real, if intentionally partial, design: call "undesigned" — now has a real, if intentionally partial, design:
`ingest` (AGPL core) gained an optional `TenantResolver` `ingest` (AGPL core) gained an optional `TenantResolver`
(`ingest/internal/grpcserver`), a per-tenant bearer credential an agent (`ingest/internal/grpcserver`), a per-tenant bearer credential an agent
@@ -271,11 +274,29 @@ cause an index directory to be created for a tenant that's no longer
active. Narrow blast radius (an orphan, isolated, empty index — not active. Narrow blast radius (an orphan, isolated, empty index — not
cross-tenant leakage — and only reachable with a real signed cross-tenant leakage — and only reachable with a real signed
credential), but real; see `search/src/registry.rs`'s doc comment on credential), but real; see `search/src/registry.rs`'s doc comment on
`resolve`. What still keeps this phase from being done: only the `resolve`. **The tenant-picker page is now built too**:
tenant-picker *page* now — `web` has no session/cookie-handling code at `web/src/routes/select-tenant` calls `GET /auth/memberships`/
all yet, and `enterprise-auth` has no CORS middleware for a cross-origin `POST /auth/select-tenant` via `fetch(..., {credentials: 'include'})`
`fetch` with credentials, both real, separately-scoped frontend gaps. (new `$lib/api.ts` functions), which needed a second CORS posture
Full accounting: alongside the wildcard-friendly one `enterprise-api` already had —
`api/httpserver.WithCredentialedCORS`, set to a literal origin via a new
`CORS_ALLOWED_ORIGIN` on `enterprise-auth` — since browsers refuse to
honor a wildcard `Access-Control-Allow-Origin` on a credentialed
request. **Genuinely verified in a real browser in this environment**:
a throwaway Node server standing in for `enterprise-auth`'s exact wire
contract (including its plain-text `http.Error` bodies, not JSON) on a
different origin than `web`'s dev server, driven through the full
cross-origin pending-login-cookie round trip, a real click choosing a
tenant, and the post-selection redirect — plus the missing/expired-
pending-login error path — with 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 that package's own
tests). See `/web/README.md`'s "Tenant picker" section for the exact
setup. What's left in this phase now is entirely the caveats already
disclosed above, not an unbuilt feature: the ClickHouse/Postgres-backed
pieces have never run against a real database in this environment, and
nothing here has been tried against a real external IdP or a real
running multi-container deployment. Full accounting:
`/docs/security/threat-model.md`; step-by-step verification procedure `/docs/security/threat-model.md`; step-by-step verification procedure
(not yet run against a live cluster in this environment): (not yet run against a live cluster in this environment):
`/docs/phase-4-runbook.md`. The rest of this section describes the exit `/docs/phase-4-runbook.md`. The rest of this section describes the exit
+26
View File
@@ -22,3 +22,29 @@ func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
// WithCredentialedCORS is WithCORS's sibling for endpoints a browser must
// call with cookies attached (Phase 4: enterprise-auth's
// GET /auth/memberships / POST /auth/select-tenant, called from web's
// tenant-picker page via `fetch(..., {credentials: 'include'})`).
// Browsers categorically refuse to combine a credentialed request with
// Access-Control-Allow-Origin: "*" -- allowedOrigin must be a real,
// literal origin (e.g. "http://localhost:3000"), not the wildcard
// WithCORS's own zero-config default relies on. Callers of this function
// don't get that convenient zero-config default: an empty/wildcard
// allowedOrigin here is a configuration bug, not a permissive default,
// so it's deliberately not special-cased into something that "just
// works" the way plain WithCORS's "*" does.
func WithCredentialedCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+41
View File
@@ -40,3 +40,44 @@ func TestWithCORSPassesThroughNonPreflight(t *testing.T) {
t.Fatalf("status = %d, want 200", rec.Code) t.Fatalf("status = %d, want 200", rec.Code)
} }
} }
func TestWithCredentialedCORSSetsLiteralOriginAndCredentialsHeader(t *testing.T) {
inner := http.NewServeMux()
inner.HandleFunc("GET /auth/memberships", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCredentialedCORS(inner, "http://localhost:3000")
req := httptest.NewRequest(http.MethodGet, "/auth/memberships", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("Access-Control-Allow-Origin = %q, want http://localhost:3000", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("Access-Control-Allow-Credentials = %q, want true -- a credentialed fetch() needs this header present or the browser discards the response", got)
}
}
func TestWithCredentialedCORSPreflight(t *testing.T) {
inner := http.NewServeMux()
inner.HandleFunc("POST /auth/select-tenant", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCredentialedCORS(inner, "http://localhost:3000")
req := httptest.NewRequest(http.MethodOptions, "/auth/select-tenant", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("Access-Control-Allow-Credentials = %q, want true on the preflight response too", got)
}
}
+5 -4
View File
@@ -58,8 +58,9 @@
// (live-Postgres, skip-gated). // (live-Postgres, skip-gated).
// //
// Scope boundary all four items share: they prove *read* isolation // Scope boundary all four items share: they prove *read* isolation
// given tenant-scoped data exists -- they do not prove ingest/write-path // given tenant-scoped data exists -- they say nothing about ingest's
// tenancy, which doesn't exist yet (every record ingest produces lands // write path, which is now a separately-built and separately-verified
// in the single shared ClickHouse database and Tantivy index regardless // concern (enterprise/cmd/enterprise-ingest + enterprise/internal/
// of tenant) -- see /docs/security/threat-model.md. // chwriter for ClickHouse, search/src/consumer.rs for Tantivy) -- see
// /docs/security/threat-model.md and /docs/phase-4-runbook.md §14.
package queryapi package queryapi
+10 -5
View File
@@ -202,11 +202,16 @@ mutually-exclusive choice via `COMPOSE_PROFILES` (`.env` defaults to
plain `api`), sharing a host-port/network-alias trick so `alerting`/ plain `api`), sharing a host-port/network-alias trick so `alerting`/
`web` need no conditional config either way. With both storage engines' `web` need no conditional config either way. With both storage engines'
connection/index-layer mechanisms built, deployment topology enforced at connection/index-layer mechanisms built, deployment topology enforced at
both the Helm and docker-compose layers, and the two provisioning both the Helm and docker-compose layers, the two provisioning
mechanisms unified, and both storage engines' write paths now mechanisms unified, both storage engines' write paths per-tenant-routed,
per-tenant-routed too (see above), the largest remaining gap in this and the tenant-picker frontend page now built and browser-verified
phase is the tenant-picker *frontend* page — the backend protocol is (`web/src/routes/select-tenant`, `api/httpserver.WithCredentialedCORS`
built, but `web` has no session/cookie-handling code yet to call it. — see `/CLAUDE.md`'s Phase 4 section and `/web/README.md`'s "Tenant
picker" section), the remaining gaps in this phase are entirely the
already-disclosed live-verification caveats: the ClickHouse/Postgres-
backed pieces have never run against a real database in this
environment, and nothing here has been tried against a real external
IdP or a real running multi-container deployment.
## Licensing boundary ## Licensing boundary
+85 -18
View File
@@ -47,6 +47,14 @@ of what was already run and passed. Two genuine exceptions:
unverified is not Tantivy itself but the upstream credential/header unverified is not Tantivy itself but the upstream credential/header
plumbing feeding it (ingest's `TenantResolver`, `enterprise-auth`'s plumbing feeding it (ingest's `TenantResolver`, `enterprise-auth`'s
`/internal/authorize-ingest`) against a real running stack. `/internal/authorize-ingest`) against a real running stack.
- The tenant-picker frontend page (§12) -- the first frontend-only piece
in this phase exercised in a real browser rather than only
type-checked: `web/src/routes/select-tenant`'s cross-origin
credentialed fetch/CORS/cookie handling, driven end-to-end via
`mcp__claude-in-chrome` against a throwaway server standing in for
`enterprise-auth`'s exact wire contract. What's unverified is the same
shape as OIDC/SAML above: this round trip against a real running
`enterprise-auth` container, not a stand-in.
Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement
walkthrough, the dashboards tenant-scoping fix, the Helm chart, the walkthrough, the dashboards tenant-scoping fix, the Helm chart, the
@@ -530,12 +538,12 @@ the full loop (does the operator's watch actually re-trigger a reconcile
after `-provision-tenant`'s external status write the way controller- after `-provision-tenant`'s external status write the way controller-
runtime's default predicate is expected to). runtime's default predicate is expected to).
## 12. Tenant-picker backend protocol (no frontend yet, no Docker needed) ## 12. Tenant-picker, backend and frontend
Like §9, this needs nothing but a local Go toolchain -- the real **Backend** -- like §9, this needs nothing but a local Go toolchain --
fake-IdP tests already exercise the full login → pending-login cookie → the real fake-IdP tests already exercise the full login → pending-login
`GET /auth/memberships``POST /auth/select-tenant` → real session cookie → `GET /auth/memberships``POST /auth/select-tenant` → real
round trip: session round trip:
```sh ```sh
cd enterprise cd enterprise
@@ -555,13 +563,70 @@ go test ./internal/loginhandler/... -run 'Memberships|SelectTenant|MultipleMembe
# tenant_id outside the identity's actual memberships. # tenant_id outside the identity's actual memberships.
``` ```
**Not built, and explicitly not attempted here**: the frontend page. **Frontend** -- `web/src/routes/select-tenant` (new), calling the
`web` has no session/cookie-handling code anywhere in it today (checked endpoints above via `fetch(..., {credentials: 'include'})`
while designing this), and `enterprise-auth` has no CORS middleware at (`$lib/api.ts`'s `listMemberships`/`selectTenant`), needed a CORS
all -- a cross-origin `fetch` with credentials from `web`'s origin to posture `enterprise-auth` didn't have: `api/httpserver.WithCORS`'s
`enterprise-auth`'s would need it, and doesn't work today. Building the wildcard-friendly default can't be combined with a credentialed
actual picker UI is real, separately-scoped frontend work; this section request at all (browsers refuse it outright), so this needed a new
only closes the backend half. `WithCredentialedCORS` (same package, literal-origin-only) wired in via
a new `CORS_ALLOWED_ORIGIN` env var, defaulting to
`POST_LOGIN_REDIRECT_URL` (`web`'s own origin). Type-checked and built
for real:
```sh
cd web
npm run check # svelte-check -- 0 errors
npm run build # adapter-static -- confirms select-tenant.html is
# actually produced (it wasn't, at first: adapter-
# static only crawls routes reachable from a link or an
# explicit prerender entry, and nothing in the app
# links to this route since it's only ever reached via
# enterprise-auth's redirect -- fixed by adding
# select-tenant/+page.ts's `export const prerender =
# true`, the same declaration every other route here
# already has)
```
**Genuinely verified in a real browser in this environment** -- not just
type-checked, the actual cross-origin fetch/CORS/cookie behavior, driven
through `mcp__claude-in-chrome`:
1. A throwaway Node HTTP server (no dependencies) stood in for
`enterprise-auth`, implementing the exact wire contract this section's
Go tests already prove server-side: `GET /auth/memberships` and
`POST /auth/select-tenant`, the `sentry_pending_login` cookie
(`Path=/auth`), the credentialed CORS headers, and critically the
*plain-text* `http.Error` response bodies the real handler sends on
failure (not JSON -- `enterpriseAuthRequest` in `$lib/api.ts` reads
`res.text()` specifically because of this, unlike every other request
helper in that file).
2. `npm run dev` (SvelteKit dev server) pointed at that fake server via
`VITE_ENTERPRISE_AUTH_BASE_URL`, both on `localhost` but different
ports -- different origins, the same cross-origin shape a real
deployment has.
3. The browser navigated to the fake server's `/debug/start-pending-
login` (mimics `loginhandler.startTenantSelection`: sets the pending
cookie, redirects to `/select-tenant`) -- confirmed the redirect
landed on the real page, which then genuinely fetched
`GET /auth/memberships` cross-origin *with the cookie attached* and
rendered both fake tenants with their display names and roles.
4. Clicked a tenant in the real UI -- confirmed the real
`POST /auth/select-tenant` fired (with preflight), succeeded, and the
page navigated to the response's `redirect_url` via a real full page
load.
5. Reloaded `/select-tenant` directly (no pending cookie present anymore
-- the fake server clears it exactly like the real handler does) --
confirmed the page's error state renders the backend's actual
plain-text message ("missing or expired pending login...") rather
than a generic fetch-failure string.
No console errors at any point. This is the first frontend-only piece
in this entire phase that's been exercised in a real browser rather than
only type-checked or unit-tested against fakes -- everything else
frontend-adjacent (`getAuthFeatures` on the settings page, existing
Phase 0-3 routes) predates this runbook and was never re-verified here
either.
## 13. Ingest tenant identity ## 13. Ingest tenant identity
@@ -760,13 +825,15 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
- **Human SSO login now works for both OIDC (§3a) and SAML (§3b)** -- - **Human SSO login now works for both OIDC (§3a) and SAML (§3b)** --
each verified with a real fake IdP (genuine cryptographic signing and each verified with a real fake IdP (genuine cryptographic signing and
verification), not yet a real external IdP or a running verification), not yet a real external IdP or a running
`enterprise-auth` container. **The tenant-picker backend protocol is `enterprise-auth` container. **The tenant-picker, backend and
now built too** (§12) -- `GET /auth/memberships`/ frontend, is now fully built too** (§12) -- `GET /auth/memberships`/
`POST /auth/select-tenant`, backed by a short-lived pending-login `POST /auth/select-tenant`, backed by a short-lived pending-login
token distinct from a real session -- but nothing in `web` calls it token distinct from a real session, and `web/src/routes/select-tenant`
yet, so a multi-membership identity still can't actually finish actually calls it via credentialed cross-origin `fetch`, genuinely
logging in through a browser today, just through direct HTTP calls exercised in a real browser against a contract-accurate fake backend
(which is what §12's verification does). (§12). What's still not tried is the same caveat as OIDC/SAML above:
this whole round trip end-to-end against a real running
`enterprise-auth` container instead of a stand-in.
- No admin UI to create a `tenant_memberships` row, but §3a/§3b's manual - No admin UI to create a `tenant_memberships` row, but §3a/§3b's manual
SQL bootstrap is gone -- `enterprise-auth -create-tenant`/ SQL bootstrap is gone -- `enterprise-auth -create-tenant`/
`-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant` `-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant`
+20 -11
View File
@@ -245,20 +245,29 @@ 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 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 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 suite caught before it shipped, see `session.PendingLoginClaims`'s doc
comment) and redirects to a not-yet-served URL instead, backed by two comment) and redirects to `web/src/routes/select-tenant`, backed by two
new endpoints (`GET /auth/memberships`, `POST /auth/select-tenant`) that endpoints (`GET /auth/memberships`, `POST /auth/select-tenant`) that
list the identity's real tenant options and, on selection, re-derive the 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 for the chosen tenant server-side (never trusting a client-supplied
role) before issuing the real session. This is the *backend protocol* role) before issuing the real session. Both the *backend protocol* and
for tenant selection, verified with the same real-fake-IdP tests as the the *frontend page* that calls it are now built. The backend is verified
rest of `internal/loginhandler` — the frontend page that would call it with the same real-fake-IdP tests as the rest of `internal/loginhandler`.
doesn't exist (`web` has no session/cookie-handling code at all today, The frontend needed a second CORS posture — `httpserver.
and `enterprise-auth` has no CORS middleware for a cross-origin `fetch` WithCredentialedCORS`, a literal origin plus
with credentials to work), both real, separately-scoped gaps, not `Access-Control-Allow-Credentials: true`, since a credentialed `fetch`
silently approximated. `GET /auth/features` (`enterprise/internal/authhandler`) reports whether and a wildcard `Access-Control-Allow-Origin` can never be combined, so
this couldn't reuse `enterprise-api`'s wildcard-friendly `WithCORS` — and
is genuinely verified in a real browser in this environment (not just
type-checked): the full cross-origin pending-login-cookie round trip, a
real click choosing a tenant, the post-selection redirect, and the
missing/expired-cookie error path, all driven against a throwaway server
standing in for `enterprise-auth`'s exact wire contract. `GET
/auth/features` (`enterprise/internal/authhandler`) reports whether
OIDC/SAML are *configured*, for `/web`'s settings page to conditionally OIDC/SAML are *configured*, for `/web`'s settings page to conditionally
render — independent of whether a login button actually exists yet in render — independent of whether a login button actually exists yet in
the UI (it doesn't; only the HTTP endpoints do). the UI (it doesn't yet; a user still has to be sent to
`/auth/oidc/login`/`/auth/saml/login` by some means other than clicking
something in `web`, since no page links there).
**Implemented for the one machine caller.** `/alerting`'s evaluator is **Implemented for the one machine caller.** `/alerting`'s evaluator is
the sole service-to-service caller (`POST /query`, to evaluate rule the sole service-to-service caller (`POST /query`, to evaluate rule
@@ -490,7 +499,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 | | 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 — 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) | | 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) | **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 | | Multi-tenant-membership login (tenant picker) | **Backend and frontend built and verified** (`GET /auth/memberships`, `POST /auth/select-tenant`, a pending-login token distinct from a real session; `web/src/routes/select-tenant` calls it via credentialed cross-origin fetch, genuinely exercised in a real browser) — not yet tried against a real running `enterprise-auth` container |
| 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) | | 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`) | | 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 | | Audit log tamper detection (hash chain) | **Enforced**, verified live |
+33 -27
View File
@@ -139,10 +139,10 @@ Postgres-backed pieces.
## Tenant selection (multi-membership identities) ## Tenant selection (multi-membership identities)
The backend protocol for choosing a tenant is built and tested; the Both the backend protocol for choosing a tenant and the frontend page
frontend page that would actually call it is deliberately not (see that calls it (`web/src/routes/select-tenant`) are now built. When
"Deliberately deferred" below). When `resolveIdentity` finds more than `resolveIdentity` finds more than one `tenant_memberships` row for a
one `tenant_memberships` row for a logged-in identity, `finishLogin` logged-in identity, `finishLogin`
issues a `session.Manager.IssuePendingLogin` token (a distinct Go/JWT 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 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 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 memberships, a real session token rejected when presented as a pending
login). login).
**Deliberately deferred, not half-built** -- named explicitly rather than The frontend side needed two things `web` didn't have: session/cookie-
silently left out: aware requests (`$lib/api.ts`'s `listMemberships`/`selectTenant`, both
- **The actual tenant-picker page** -- nothing in `web/` calls the `fetch(..., {credentials: 'include'})`) and CORS that actually allows a
endpoints above yet. Building it is genuinely different, larger scope credentialed cross-origin request -- `httpserver.WithCredentialedCORS`
than the backend protocol: `web` has zero session/cookie-handling (new, in `api/httpserver`, next to the plain `WithCORS` every other
code today (confirmed by reading it end to end while designing this), service in this repo uses), wired in by this binary's `main.go` and
so a real picker page means adding that from scratch, plus CORS configured via the new `CORSAllowedOrigin` field
wiring (`enterprise-auth` has no CORS middleware at all right now -- (`CORS_ALLOWED_ORIGIN`, defaulting to `PostLoginRedirectURL` --
a cross-origin `fetch` with credentials from `web`'s origin needs `web`'s own origin is exactly what needs credentialed access here).
it), neither of which is verifiable in this environment without a Browsers categorically refuse to honor `Access-Control-Allow-Origin: "*"`
live backend and a browser session to exercise. 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 Two other things once named as deferred here are built too: ingest
list -- see "Ingest write-routing (ClickHouse)" below and write-routing, both ClickHouse (see "Ingest write-routing (ClickHouse)"
`/search/README.md`'s "Per-tenant indices" section for Tantivy, which below) and Tantivy (`/search/README.md`'s "Per-tenant indices" section
needed no code in this module at all: `search`'s `IndexRegistry` already -- needed no code in this module at all, since `search`'s
lived in AGPL core, so its write side didn't need an `enterprise/` `IndexRegistry` already lived in AGPL core); and deployment-topology
counterpart the way ClickHouse's did. routing (does traffic actually reach `enterprise-api` instead of
`api`), now a single-flag choice in both `deploy/helm/sentry` and
Deployment-topology routing (does traffic actually reach `enterprise-api` `docker-compose.yml` (`enterprise.enabled` / `COMPOSE_PROFILES`), see
instead of `api`) is no longer deferred -- both `deploy/helm/sentry` and CLAUDE.md.
`docker-compose.yml` make it a single-flag choice now (`enterprise.
enabled` / `COMPOSE_PROFILES`), see CLAUDE.md.
## Ingest tenant identity ## 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) | | `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 | | `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 | | `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`) ## Environment variables (`enterprise-api`)
+9 -1
View File
@@ -37,6 +37,7 @@ import (
"github.com/crewjam/saml/samlsp" "github.com/crewjam/saml/samlsp"
"github.com/jackc/pgx/v5/pgxpool" "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/authhandler"
"github.com/sentry/sentry/enterprise/internal/config" "github.com/sentry/sentry/enterprise/internal/config"
"github.com/sentry/sentry/enterprise/internal/loginhandler" "github.com/sentry/sentry/enterprise/internal/loginhandler"
@@ -202,7 +203,14 @@ func main() {
authhandler.New(logger, sessionManager, features, rbac).RegisterRoutes(mux) authhandler.New(logger, sessionManager, features, rbac).RegisterRoutes(mux)
loginhandler.New(logger, oidcProvider, samlProvider, sessionManager, rbac, cfg.PostLoginRedirectURL, cfg.SelectTenantRedirectURL).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) errCh := make(chan error, 1)
go func() { go func() {
+18 -7
View File
@@ -20,13 +20,21 @@ type Config struct {
// SelectTenantRedirectURL is where the browser lands after a login // SelectTenantRedirectURL is where the browser lands after a login
// resolves to more than one tenant_memberships row -- // resolves to more than one tenant_memberships row --
// internal/loginhandler issues a pending-login cookie and sends the // internal/loginhandler issues a pending-login cookie and sends the
// browser here instead of straight to PostLoginRedirectURL. Nothing // browser here. web/src/routes/select-tenant is the page that serves
// serves this route yet (a real tenant-picker page is undesigned // it (see that route's own comments) -- it calls GET
// frontend work -- see internal/loginhandler's package doc comment); // /auth/memberships and POST /auth/select-tenant with
// the backend protocol (GET /auth/memberships, POST // `credentials: 'include'`, which is why CORSAllowedOrigin below has
// /auth/select-tenant) is complete and independently testable via // to be a literal origin, not WithCORS's zero-config "*" default.
// HTTP regardless of what, if anything, is listening here today.
SelectTenantRedirectURL string 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 { type PostgresConfig struct {
@@ -84,8 +92,11 @@ func Load() (Config, error) {
// computed after cfg.PostLoginRedirectURL above so a caller // computed after cfg.PostLoginRedirectURL above so a caller
// overriding just POST_LOGIN_REDIRECT_URL still gets a sensible // overriding just POST_LOGIN_REDIRECT_URL still gets a sensible
// SelectTenantRedirectURL without also having to set the new // 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.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 // Required, unlike OIDC/SAML above: every enterprise-auth deployment
// issues and validates session/service tokens (internal/session), // issues and validates session/service tokens (internal/session),
@@ -17,10 +17,10 @@
// who they are, commits to no tenant yet) and redirects to // who they are, commits to no tenant yet) and redirects to
// selectTenantRedirectURL instead of issuing a session outright. // selectTenantRedirectURL instead of issuing a session outright.
// GET /auth/memberships and POST /auth/select-tenant complete the round // GET /auth/memberships and POST /auth/select-tenant complete the round
// trip. The backend protocol is complete and independently testable via // trip. web/src/routes/select-tenant is the frontend page that calls
// HTTP; the frontend page that would actually call it doesn't exist yet // them, over credentialed cross-origin fetch (see
// (a real tenant-picker UI is undesigned, separately-scoped frontend // httpserver.WithCredentialedCORS and config.CORSAllowedOrigin) -- see
// work -- see config.SelectTenantRedirectURL's doc comment). // that route's own comments for the page itself.
package loginhandler package loginhandler
import ( import (
+40
View File
@@ -38,6 +38,46 @@ docker build -f Dockerfile -t sentry-web . # context is web/, not the repo roo
docker run -p 3000:3000 sentry-web docker run -p 3000:3000 sentry-web
``` ```
## Tenant picker (Phase 4)
`src/routes/select-tenant` is the one route that isn't reachable by
clicking around the app -- `enterprise-auth`'s `internal/loginhandler`
redirects a browser here after an SSO login resolves to more than one
`tenant_memberships` row (see that package's doc comment), carrying a
short-lived `sentry_pending_login` cookie instead of a real session. The
page calls `GET /auth/memberships` to list the choices, and
`POST /auth/select-tenant` on a click, both via
`fetch(..., {credentials: 'include'})` (`$lib/api.ts`'s
`listMemberships`/`selectTenant`) so that cookie -- and, on success, the
real session cookie the POST response sets -- actually cross the origin
boundary between this app and `enterprise-auth`. `enterprise-auth`'s
default `POST_LOGIN_REDIRECT_URL` (this app's own base URL) is also
where `CORS_ALLOWED_ORIGIN` defaults to, and it has to be a literal
origin, not `*` -- see `api/httpserver.WithCredentialedCORS`'s doc
comment for why a credentialed `fetch` and a wildcard CORS origin can
never be combined; `getAuthFeatures` above deliberately doesn't send
credentials for exactly this reason, and is why it could stay on the
plain `WithCORS` every other endpoint in this repo uses.
Like every other route (`export const prerender = true` in this route's
own `+page.ts`), no server-side data loading -- the membership list and
the tenant choice both come from client-side `fetch` calls the same way
the root query page's does.
Verified in a real browser in this environment: a throwaway Node server
standing in for `enterprise-auth` (implementing the exact
`GET /auth/memberships`/`POST /auth/select-tenant` wire contract,
including the plain-text `http.Error` bodies the real handler sends, not
JSON) on a different origin/port than this app's dev server, driven
through the full flow -- cross-origin pending-login cookie set, the
credentialed preflight + `GET`/`POST` round trip, a real click choosing
a tenant, and the post-selection redirect landing back on `/` -- plus
the missing/expired-cookie error path separately. No Docker or live
Postgres/IdP needed for this, since the whole point was exercising this
app's own fetch/CORS/cookie wiring against a contract-accurate fake, not
`enterprise-auth`'s internals (those are `enterprise/internal/
loginhandler`'s own tests' job, already covered there).
## Why nginx, not distroless ## Why nginx, not distroless
The repo convention prefers distroless/scratch base images. Serving a The repo convention prefers distroless/scratch base images. Serving a
+54
View File
@@ -149,6 +149,60 @@ export async function getAuthFeatures(): Promise<AuthFeatures> {
} }
} }
// --- tenant picker (Phase 4) -------------------------------------------
//
// The two calls below are the reason getAuthFeatures above doesn't send
// credentials but these do: they carry the short-lived
// sentry_pending_login cookie enterprise-auth's finishLogin sets when an
// identity resolves to more than one tenant_memberships row (see
// enterprise/internal/loginhandler's package doc comment), and
// selectTenant's response sets the real session cookie. Both require
// `credentials: 'include'`, which is exactly why enterprise-auth's CORS
// (httpserver.WithCredentialedCORS) can't use getAuthFeatures'/api.ts's
// other requests' wildcard-friendly posture -- browsers refuse to honor
// Access-Control-Allow-Origin: "*" on a credentialed request at all, so
// CORS_ALLOWED_ORIGIN has to name this page's real origin.
export type Membership = { tenant_id: string; tenant_display_name: string; role: string };
class TenantPickerError extends Error {}
// enterprise-auth's loginhandler responds to an error with plain
// http.Error text (e.g. "no membership in the requested tenant"), not a
// JSON {"error": "..."} body the way /api's queryapi/dashboards handlers
// do -- requestFrom's JSON-body error parsing wouldn't surface that
// message, so this reads the body as plain text instead.
async function enterpriseAuthRequest<T>(path: string, init?: RequestInit): Promise<T> {
if (!enterpriseAuthBase) {
throw new TenantPickerError('enterprise-auth is not configured (VITE_ENTERPRISE_AUTH_BASE_URL unset)');
}
const res = await fetch(`${enterpriseAuthBase}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
...init
});
if (!res.ok) {
const body = await res.text();
throw new TenantPickerError(body || `request failed with status ${res.status}`);
}
return res.json();
}
// listMemberships backs the tenant-picker page's initial load -- see
// web/src/routes/select-tenant. A 400/401 (missing or expired pending
// login) surfaces as a thrown TenantPickerError; the page's own error
// state is what tells the user to start over at login.
export function listMemberships(): Promise<Membership[]> {
return enterpriseAuthRequest('/auth/memberships');
}
export function selectTenant(tenantId: string): Promise<{ redirect_url: string }> {
return enterpriseAuthRequest('/auth/select-tenant', {
method: 'POST',
body: JSON.stringify({ tenant_id: tenantId })
});
}
export function exportDashboard(id: string): Promise<Dashboard> { export function exportDashboard(id: string): Promise<Dashboard> {
return request(`/dashboards/${id}/export`); return request(`/dashboards/${id}/export`);
} }
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
// Phase 4's tenant-picker page: enterprise-auth's finishLogin lands
// the browser here (SELECT_TENANT_REDIRECT_URL, default
// http://localhost:3000/select-tenant -- see enterprise/internal/
// config's doc comment) after a login resolves to more than one
// tenant_memberships row, carrying a short-lived sentry_pending_login
// cookie instead of a real session. This page's whole job: show the
// choices GET /auth/memberships returns, and turn a click into
// POST /auth/select-tenant, which trades that cookie for a real
// session and tells us where to go next.
//
// No +page.ts -- everything here is client-only (fetch with
// credentials against a different origin), nothing to prerender or
// load server-side, same reasoning as every other data-fetching route
// in this app.
import { listMemberships, selectTenant, type Membership } from '$lib/api';
let phase = $state<'loading' | 'ready' | 'error'>('loading');
let memberships = $state<Membership[]>([]);
let error = $state('');
let selectingTenantId = $state('');
async function load() {
phase = 'loading';
error = '';
try {
memberships = await listMemberships();
phase = 'ready';
} catch (e) {
error = e instanceof Error ? e.message : String(e);
phase = 'error';
}
}
load();
async function choose(tenantId: string) {
selectingTenantId = tenantId;
error = '';
try {
const { redirect_url } = await selectTenant(tenantId);
// Full navigation, not SvelteKit's router: redirect_url is
// enterprise-auth's postLoginRedirectURL, i.e. this app's own
// base URL -- reloading picks up the real session cookie
// POST /auth/select-tenant just set, which client-side
// routing wouldn't need to know about but a fresh page load
// makes unambiguous.
window.location.href = redirect_url;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
selectingTenantId = '';
}
}
</script>
<main>
<h1>Select a workspace</h1>
{#if phase === 'loading'}
<p>Loading…</p>
{:else if phase === 'error' && memberships.length === 0}
<p class="error">{error}</p>
<p class="note">Your login link may have expired. Start over by logging in again.</p>
{:else}
{#if error}
<p class="error">{error}</p>
{/if}
<ul>
{#each memberships as m (m.tenant_id)}
<li>
<button
disabled={selectingTenantId !== ''}
onclick={() => choose(m.tenant_id)}
>
<span class="name">{m.tenant_display_name}</span>
<span class="role">{m.role}</span>
{#if selectingTenantId === m.tenant_id}<span class="note">Signing in…</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 480px;
margin: 3rem auto;
padding: 0 1rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
button {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
font-size: 1rem;
text-align: left;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: #06c;
}
button:disabled {
cursor: default;
opacity: 0.6;
}
.name {
flex: 1;
font-weight: 600;
}
.role {
color: #666;
font-size: 0.85rem;
text-transform: capitalize;
}
.error {
color: #b00020;
}
.note {
color: #666;
font-size: 0.85rem;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// Same shape as settings/+page.ts and dashboards/+page.ts: no route
// params, data comes from a client-side fetch (here, credentialed —
// see this route's +page.svelte and $lib/api.ts's listMemberships).
export const prerender = true;