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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user