Phase 4: real OIDC human login (enterprise/internal/loginhandler)
Closes the other major named gap from this phase: until now, there was no way for a human to actually log in -- only /alerting's RoleService credential could be minted. GET /auth/oidc/login and GET /auth/oidc/callback drive the real coreos/go-oidc flow already wired in enterprise/internal/oidc: CSRF state in a short-lived cookie, code exchange, ID token verification, upserting a users row, resolving tenant/role from exactly one tenant_memberships row (refusing outright on zero or more than one, rather than guessing), and issuing a real session cookie. Unlike everything else built this phase, this one is genuinely verified end to end: the tests spin up coreos/go-oidc's own oidctest fake IdP, which signs real RS256 ID tokens, and drive the full login->callback-> session-cookie round trip through actual signature verification -- no live database or Docker needed, so nothing here is asserted without having actually been run in this session. Also fixes a real bug caught while wiring this into enterprise-auth's main.go: assigning a nil *oidc.Provider to the handler's interface field would have produced a non-nil interface wrapping a nil pointer (Go's classic typed-nil trap), silently breaking the "OIDC not configured" no-op path -- New() now takes the concrete pointer type and checks it before ever converting to the interface, with a regression test pinning the fix down. Still missing: SAML's equivalent (ACS endpoint), a tenant-picker UI for multi-membership identities, and any admin UI to actually create a tenant_memberships row (today that's manual SQL, documented in the runbook's new bootstrap walkthrough).
This commit is contained in:
@@ -79,7 +79,7 @@ This split is not to be changed without discussion — see CLAUDE.md.
|
||||
| `search` (Rust, Phase 1) | Consumes the same Redpanda topic `ingest` does (own offset tracking), builds a Tantivy full-text index over `message`, serves matches over gRPC. One shared index for every tenant today — see "Tenant isolation" below. |
|
||||
| `api` (Go) | gRPC + REST gateway. `POST /query` compiles pipe-syntax or raw SQL to one IR, executed across ClickHouse/Tantivy (`/docs/query-language-design.md`). `internal/dashboards` is CRUD only — panel query execution happens client-side, reusing `/query`. `internal/authz` (Phase 4) enforces RBAC via a network call to `enterprise-auth`, never an import. |
|
||||
| `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). |
|
||||
| `enterprise` (Go, commercial license, Phase 4) | SSO (OIDC/SAML protocol mechanics), RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Does **not** yet include per-tenant Tantivy routing or the OIDC/SAML login HTTP handlers — see `/docs/security/threat-model.md`. |
|
||||
| `enterprise` (Go, commercial license, Phase 4) | OIDC login (`internal/loginhandler`'s `/auth/oidc/login`+`/auth/oidc/callback`, real IdP round trip, verified with a fake IdP but not a real external one), RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Does **not** yet include per-tenant Tantivy routing or SAML's login ACS handler (protocol mechanics only) — see `/docs/security/threat-model.md`. |
|
||||
| `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling enterprise-licensed components. |
|
||||
| `cli` (`sentryctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$SENTRYCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). |
|
||||
| `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. |
|
||||
|
||||
+73
-8
@@ -11,14 +11,19 @@ stack**, not asserted. This one is different, and says so plainly rather
|
||||
than papering over it: for the great majority of this phase's work,
|
||||
**there was no working Docker daemon access and no reachable Kubernetes
|
||||
cluster**, so most of what follows is a *procedure to run*, not a report
|
||||
of what was already run and passed. One genuine exception, verified live
|
||||
against a real Postgres earlier in this phase's work (see its own doc
|
||||
comments for the exact `docker run` invocations, and note this was
|
||||
before the environment lost Docker access, not a claim about this
|
||||
runbook's own session):
|
||||
of what was already run and passed. Two genuine exceptions:
|
||||
|
||||
- `enterprise/internal/audit`'s hash-chain, tamper-detection, and
|
||||
concurrent-write guarantees (task 4).
|
||||
concurrent-write guarantees (task 4) -- verified live against a real
|
||||
Postgres earlier in this phase's work (see its own doc comments for
|
||||
the exact `docker run` invocations), before the environment lost
|
||||
Docker access.
|
||||
- `enterprise/internal/loginhandler`'s full OIDC login flow (§3a) --
|
||||
verified with real cryptography (a fake IdP that signs and verifies
|
||||
genuine RS256 tokens) *without* needing Docker or a live database at
|
||||
all, so this one was actually run in this runbook's own session, not
|
||||
just an earlier one. What's still unverified is wiring it into a real
|
||||
running `enterprise-auth` container against a real external IdP.
|
||||
|
||||
Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement
|
||||
walkthrough, the dashboards tenant-scoping fix, the Helm chart, the
|
||||
@@ -85,6 +90,61 @@ curl -s http://localhost:8082/auth/features
|
||||
# (no OIDC_ISSUER_URL/SAML_IDP_METADATA_URL set in this compose file)
|
||||
```
|
||||
|
||||
## 3a. `enterprise-auth`: human login via OIDC (new -- unlike everything
|
||||
else in this runbook, the underlying flow *was* verified live in this
|
||||
session, just not against a real running `enterprise-auth` container or
|
||||
a real external IdP)
|
||||
|
||||
`enterprise/internal/loginhandler`'s tests already prove the mechanism
|
||||
works end to end against a real fake IdP (`go test
|
||||
./internal/loginhandler/... -v` from `enterprise/`, no Docker needed --
|
||||
see `enterprise/README.md`). What's still unverified is wiring it into
|
||||
this actual running stack. To try that for real, point
|
||||
`docker-compose.yml`'s `enterprise-auth` service at a real OIDC IdP
|
||||
(a free Auth0/Okta developer tenant, or any IdP you control):
|
||||
|
||||
```sh
|
||||
# Add to enterprise-auth's environment in docker-compose.yml (or a
|
||||
# docker-compose.override.yml):
|
||||
# OIDC_ISSUER_URL: "https://your-tenant.example.com/"
|
||||
# OIDC_CLIENT_ID: "..."
|
||||
# OIDC_CLIENT_SECRET: "..."
|
||||
# OIDC_REDIRECT_URL: "http://localhost:8082/auth/oidc/callback"
|
||||
# Register that same redirect URL with the IdP's application config.
|
||||
|
||||
docker compose up -d --build enterprise-auth
|
||||
curl -s http://localhost:8082/auth/features
|
||||
# expect: {"sso_configured":true,"oidc_enabled":true,"saml_enabled":false}
|
||||
```
|
||||
|
||||
Before a login can succeed, the logging-in identity needs a
|
||||
`tenant_memberships` row -- there's no admin UI for this yet, so insert
|
||||
one directly:
|
||||
|
||||
```sh
|
||||
docker run --rm --network sentry_default postgres:16-alpine psql \
|
||||
"postgres://sentry:sentry-dev-only@metadata-postgres:5432/sentry_metadata" -c \
|
||||
"INSERT INTO tenants (id, display_name, status) VALUES ('acme', 'Acme Corp', 'active') ON CONFLICT DO NOTHING;"
|
||||
# The users row is created automatically on first login (UpsertUserBySSO)
|
||||
# -- but tenant_memberships needs the user's ID, which doesn't exist
|
||||
# until after a first login attempt fails with 403. Log in once (it'll
|
||||
# fail with "no tenant membership"), then:
|
||||
docker run --rm --network sentry_default postgres:16-alpine psql \
|
||||
"postgres://sentry:sentry-dev-only@metadata-postgres:5432/sentry_metadata" -c \
|
||||
"SELECT id, email FROM users;"
|
||||
docker run --rm --network sentry_default postgres:16-alpine psql \
|
||||
"postgres://sentry:sentry-dev-only@metadata-postgres:5432/sentry_metadata" -c \
|
||||
"INSERT INTO tenant_memberships (id, tenant_id, user_id, role) VALUES (gen_random_uuid(), 'acme', '<user id from above>', 'viewer');"
|
||||
```
|
||||
|
||||
Then visit `http://localhost:8082/auth/oidc/login` in a real browser,
|
||||
complete the IdP's login, and confirm you land on
|
||||
`POST_LOGIN_REDIRECT_URL` (`http://localhost:3000` by default) with a
|
||||
`sentry_session` cookie set. This whole bootstrap sequence (manual SQL
|
||||
to create the first tenant membership) is exactly the kind of rough
|
||||
edge an admin UI would smooth over -- named as real future work, not
|
||||
hidden.
|
||||
|
||||
## 4. Turn on RBAC enforcement and prove it actually blocks/allows
|
||||
|
||||
Without touching the main stack's `api` container (so step 2's baseline
|
||||
@@ -236,8 +296,13 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
|
||||
- **No Tantivy/free-text isolation at all**, regardless of which binary
|
||||
serves the request -- `enterprise/internal/searchclient` (chrunner's
|
||||
Tantivy-side sibling) doesn't exist.
|
||||
- **No human SSO login.** OIDC/SAML protocol wiring exists;
|
||||
the HTTP login/callback handlers that would use it don't.
|
||||
- **Human SSO login now works for OIDC** (§3a) -- verified with a real
|
||||
fake IdP, not yet a real external one or a running `enterprise-auth`
|
||||
container. **SAML login still doesn't exist** -- protocol wiring only,
|
||||
no ACS handler. No tenant-picker UI for a multi-membership identity
|
||||
either (refused outright).
|
||||
- No admin UI to create a `tenant_memberships` row -- §3a's manual SQL
|
||||
bootstrap is the only way to grant a logged-in identity access today.
|
||||
- **No per-resource dashboard grants** (`dashboard_permissions` has a
|
||||
schema, no handler reads it).
|
||||
- Two of the four adversarial ClickHouse/Tantivy probes named in
|
||||
|
||||
@@ -87,6 +87,10 @@ api/alerting ──▶ enterprise-auth (POST /internal/authorize, HTTP only —
|
||||
no Go import edge, see "Module
|
||||
boundary" below)
|
||||
|
||||
Browser ──▶ enterprise-auth (GET /auth/oidc/login, /auth/oidc/callback)
|
||||
└─▶ external IdP (OIDC authorization code flow)
|
||||
└─▶ Postgres (rbacstore: users, tenant_memberships)
|
||||
|
||||
sentryctl ──▶ api, alerting (Bearer token when SENTRYCTL_TOKEN is set)
|
||||
```
|
||||
|
||||
@@ -113,15 +117,37 @@ to `enterprise-auth` — see "Deployment/network assumptions" below.
|
||||
|
||||
## Authentication
|
||||
|
||||
**Not implemented for human users.** `enterprise/internal/oidc` and
|
||||
`enterprise/internal/saml` wire `coreos/go-oidc`/`crewjam/saml` for the
|
||||
protocol mechanics (discovery, AuthnRequest generation, token/assertion
|
||||
validation), but no HTTP handler calls them — there is no
|
||||
`/auth/oidc/login`, `/auth/oidc/callback`, or SAML ACS endpoint. A human
|
||||
cannot log in today. `GET /auth/features` (`enterprise/internal/
|
||||
authhandler`) reports whether OIDC/SAML are *configured* (for `/web`'s
|
||||
settings page to conditionally render), which is independent of whether
|
||||
login actually works.
|
||||
**Implemented for OIDC, still missing for SAML.**
|
||||
`enterprise/internal/loginhandler` serves `GET /auth/oidc/login`
|
||||
(redirects to the configured IdP, with a short-lived HttpOnly cookie
|
||||
carrying CSRF-protection state) and `GET /auth/oidc/callback`
|
||||
(validates state, exchanges the code, verifies the ID token via
|
||||
`enterprise/internal/oidc`'s real `coreos/go-oidc` wiring, upserts a
|
||||
`users` row keyed by SSO subject, resolves tenant/role from
|
||||
`tenant_memberships`, and issues a `session.Manager`-signed session
|
||||
cookie). Verified end-to-end with real cryptography, not mocked: the
|
||||
tests spin up a real fake IdP (`coreos/go-oidc`'s own `oidctest`
|
||||
package) that signs genuine RS256 ID tokens, and
|
||||
`enterprise/internal/loginhandler`'s handler verifies them for real via
|
||||
the same code path production uses — every test in
|
||||
`loginhandler_test.go` passes, including the full login→callback→
|
||||
session-cookie round trip. **Not yet verified**: wiring this into a
|
||||
running `enterprise-auth` container against a *real* external IdP
|
||||
(Google/Okta/etc.) — that needs real IdP credentials and a reachable
|
||||
callback URL neither of which this environment has; see
|
||||
`/docs/phase-4-runbook.md`.
|
||||
|
||||
A user with zero or more than one `tenant_memberships` row is refused
|
||||
outright (403 / 501 respectively) rather than guessed at — a
|
||||
tenant-selection UI for the multi-membership case is real, undesigned
|
||||
future work, not silently approximated. `enterprise/internal/saml` still
|
||||
only does the protocol mechanics (AuthnRequest generation, assertion
|
||||
validation) with no ACS HTTP handler calling it — SAML login remains
|
||||
unimplemented, following `loginhandler`'s OIDC pattern once it is built.
|
||||
`GET /auth/features` (`enterprise/internal/authhandler`) reports whether
|
||||
OIDC/SAML are *configured*, for `/web`'s settings page to conditionally
|
||||
render — independent of whether a login button actually exists yet in
|
||||
the UI (it doesn't; only the two HTTP endpoints do).
|
||||
|
||||
**Implemented for the one machine caller.** `/alerting`'s evaluator is
|
||||
the sole service-to-service caller (`POST /query`, to evaluate rule
|
||||
@@ -327,7 +353,9 @@ terms:
|
||||
| `system.*` ClickHouse metadata isolation | **Built, not live-verified** — same caveat as above |
|
||||
| Tantivy/free-text tenant isolation | **Not implemented** — no per-tenant index routing at all |
|
||||
| Deployment actually routing traffic to `enterprise-api` | **Not implemented** — no Helm service, no default wiring |
|
||||
| Human SSO login (OIDC/SAML) | **Not implemented** |
|
||||
| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
|
||||
| Human SSO login — SAML | **Not implemented** |
|
||||
| Multi-tenant-membership login (tenant picker) | **Not implemented** — refused with a clear error, not guessed |
|
||||
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user