Files
cairnobs/docs/phase-4-runbook.md
T
jcoffey-dev 1fab02abd5 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).
2026-08-13 23:00:35 -07:00

16 KiB

Phase 4 runbook

Extends /docs/phase-0-runbook.md through /docs/phase-3-runbook.md with SSO plumbing, RBAC enforcement, tenant-scoped dashboards, audit logging, and a Kubernetes deployment path. Read those first.

Verification status — read this before the rest of this doc

Every prior phase's runbook documents claims checked against the live 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. Two genuine exceptions:

  • enterprise/internal/audit's hash-chain, tamper-detection, and 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 tenant-operator, and (newest) internal/tenantprovision/ internal/chrunner's live-ClickHouse tests — has unit/fake-client/ helm template coverage (all passing, see each component's own go test/helm lint output, including every Skip*-gated integration test confirmed to skip cleanly offline) but has not been exercised against a real running stack. Be specific when citing this runbook: "the tests exist and pass structurally" is a true, verified claim; "isolation was confirmed against real ClickHouse" is not, yet. If you're reading this to decide whether Phase 4 is production-ready: it isn't yet, independent of this gap — see /docs/security/threat-model.md's headline finding. This runbook exists so the first person with real Docker/K8s access can actually close the loop, not to claim that already happened.

1. Bring up the stack

docker compose build enterprise-auth api alerting web
docker compose up -d
docker compose ps

New service beyond Phase 3: enterprise-auth (port 8082) — see enterprise/README.md. Not wired into api/alerting's enforcement by default (docker-compose.yml's comment on why: no OIDC/SAML login flow exists yet, so turning on enforcement by default would break the web UI and sentryctl with no way to log in).

2. Confirm Phase 0-3 behavior is unchanged

Every existing single-tenant flow must still work exactly as before — this is the regression check for the nil-authorizer no-op design running through every piece of Phase 4 auth wiring:

curl -s -X POST http://localhost:8080/query -H 'Content-Type: application/json' -d '{"query":"stats count"}'
sentryctl dashboards list
curl -s http://localhost:8081/healthz

All three should behave exactly as in the Phase 3 runbook — no auth required, since ENTERPRISE_AUTH_URL/API_SERVICE_TOKEN aren't set.

3. enterprise-auth: mint and validate a service token

curl -s http://localhost:8082/healthz && echo " <- OK"

TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=alerting)
echo "$TOKEN"

curl -s -X POST http://localhost:8082/internal/authorize -H "Authorization: Bearer $TOKEN"
# expect: {"tenant_id":"","user_id":"","role":"service"}

curl -s -o /dev/null -w "invalid token -> %{http_code}\n" \
  -X POST http://localhost:8082/internal/authorize -H "Authorization: Bearer garbage"
# expect: 401

curl -s http://localhost:8082/auth/features
# expect: {"sso_configured":false,"oidc_enabled":false,"saml_enabled":false}
# (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):

# 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:

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 keeps working):

docker compose run --rm -d --name sentry-api-enforced -p 8090:8080 \
  -e ENTERPRISE_AUTH_URL=http://enterprise-auth:8082 api

curl -s -o /dev/null -w "no auth -> %{http_code} (want 401)\n" \
  -X POST http://localhost:8090/query -H 'Content-Type: application/json' -d '{"query":"stats count"}'
curl -s -o /dev/null -w "with service token -> %{http_code} (want 200)\n" \
  -X POST http://localhost:8090/query -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"query":"stats count"}'

docker stop sentry-api-enforced

GET /dashboards on the same enforced instance should return 401 without a token — there's no way to mint a human (Viewer/Editor/etc.) session yet (no OIDC/SAML login handler exists — see enterprise/cmd/enterprise-auth/main.go's doc comment), so this runbook can't walk through a real human RBAC scenario end to end. That gap is real, not an oversight in this runbook.

5. Dashboards tenant scoping

This is the fix from Phase 4 task 7/8 (see /docs/security/threat-model.md) — every dashboards query is now scoped to the authenticated identity's tenant. Verify the real SQL, not just the fake-store unit tests:

docker run --rm --network sentry_default -v $(pwd)/api:/src -w /src \
  -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
  -e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \
  golang:1.25-alpine go test ./internal/dashboards/... -run Integration -v

Expect all TestIntegration* tests to pass, including TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant (the tenant_id foreign key added in metadata/migrations/0027_add_dashboards_tenant_fk.sql rejecting a dashboard for a tenant that doesn't exist).

6. enterprise/internal/rbacstore and internal/audit (already verified — reconfirm here)

docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
  -e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
  -e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \
  golang:1.25-alpine go test ./internal/rbacstore/... -v

docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
  -e AUDIT_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
  -e AUDIT_TEST_POSTGRES_PASSWORD=audit-writer-dev-only \
  -e AUDIT_TEST_ADMIN_PASSWORD=sentry-dev-only \
  golang:1.25-alpine go test ./internal/audit/... -v

7. deploy: Helm chart and Operator (offline-only so far — see /deploy/README.md)

No live cluster was available to kubectl apply any of this. What can be checked without one:

cd deploy/operator && go build ./... && go vet ./... && go test ./...

cd ../helm/sentry
helm lint .
helm template sentry . --include-crds > /tmp/default.yaml
helm template sentry . --include-crds \
  --set enterprise.enabled=true --set tenantOperator.enabled=true \
  --set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' \
  --set 'tenants[1].name=globex' --set 'tenants[1].displayName=Globex Corporation' \
  > /tmp/multitenant.yaml

With a real cluster reachable (kind create cluster, or similar):

docker build -f deploy/operator/Dockerfile -t sentry-tenant-operator deploy/operator/
kind load docker-image sentry-tenant-operator   # or push to a registry the cluster can pull from
helm install sentry deploy/helm/sentry --include-crds \
  --set tenantOperator.enabled=true --set enterprise.enabled=true \
  --set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp'
kubectl get tenants
kubectl get secret sentry-tenant-acme-clickhouse -o yaml

Expect kubectl get tenants to show acme reach status.phase: Active and the Secret to contain a generated username/password/database. This proves the K8s-side half of a real two-tenant deployment — it does not provision a working ClickHouse database itself (the Operator manages the K8s Secret only); §8 below is the piece that actually provisions ClickHouse.

8. enterprise-api: real per-tenant ClickHouse isolation

This is new since this runbook was first written — enterprise/internal/ tenantprovision and enterprise/internal/chrunner now exist, closing the headline gap §"Known gaps" below used to describe as completely unbuilt. It's still a second binary you have to choose to run, though — see /docs/security/threat-model.md's "Read this first" section.

docker compose build enterprise-api
docker compose run --rm enterprise-api -provision-tenant=acme -display-name="Acme Corp"
docker compose run --rm enterprise-api -provision-tenant=globex -display-name="Globex Corporation"
docker compose up -d enterprise-api
curl -s http://localhost:8083/healthz

There's still no OIDC/SAML login handler and no CLI for minting a human session token (see /docs/security/threat-model.md) -- so a real curl -X POST http://localhost:8083/query walkthrough as tenant acme isn't possible yet. Confirm isolation end to end against the live stack (this is the same assertion enterprise/internal/chrunner/chrunner_test.go's TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL makes, run here as an integration test instead of a curl walkthrough since there's no login flow to drive it through curl yet):

docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
  -e CHRUNNER_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
  -e CHRUNNER_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
  golang:1.25-alpine go test ./internal/chrunner/... -v

docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
  -e TENANTPROVISION_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
  -e TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
  golang:1.25-alpine go test ./internal/tenantprovision/... -v

Expect all tests to pass, including TestProvisionedUserCannotReadSystemTables (item 2 of /docs/phase-4-isolation-design.md's verification plan, closed this pass) and TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL (item 1, closed through the actual production code path, not just tenantprovision's raw grants).

Known gaps (do not treat this phase as done without reading these)

Full accounting: /docs/security/threat-model.md. Headline items:

  • ClickHouse isolation exists but is opt-in. enterprise-api (§8) gives real per-tenant ClickHouse isolation, but plain api (still the default in docker-compose.yml/web's base URL) has none, and nothing flags which one a given deployment is actually running.
  • 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.
  • 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 /docs/phase-4-isolation-design.md's verification plan are closed (§8); the other two (Tantivy cross-tenant search, mid-provisioning-race handling) are still stubbed as explicitly-skipped tests in api/queryapi/tenant_isolation_gap_test.go.

Tearing down

docker compose down -v
helm uninstall sentry   # if installed against a real cluster

Troubleshooting

enterprise-auth fails to start with "ENTERPRISE_SESSION_SIGNING_KEY must be set to at least 32 bytes". Required, unlike OIDC/SAML config — see enterprise/internal/config.Load. docker-compose.yml's dev value is long enough; a custom override must be too.

POST /query returns 401 even though ENTERPRISE_AUTH_URL isn't set. Check api/cmd/api/main.go actually left authorizer nil when cfg.EnterpriseAuthURL == "" — a nil Authorizer must be a no-op (api/authz.RequireRole's doc comment). If this regresses, it breaks every existing Phase 0-3 deployment silently.

A dashboard created by one tenant is visible to another. This is the exact bug found and fixed in task 7 — see /docs/security/threat-model.md's "application-layer tenant scoping" section and api/dashboards/handler_test.go's TestCrossTenant* tests. If this regresses, Handler.tenantID or store.go's WHERE tenant_id = ... filters have been bypassed somewhere — check every store method still takes and uses a tenantID parameter.

helm template fails with error calling include: ... can't evaluate field Release in type string. A call site is passing a bare string to sentry.selectorLabels instead of (list $ "name") — see templates/_helpers.tpl's doc comment for why the plain-string form doesn't work with include.