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).
enterprise
Commercial license, not AGPLv3 — see /CLAUDE.md's licensing
boundary. SSO (OIDC/SAML), tenant provisioning, and RBAC. Nothing in
/agent, /ingest, /storage, /api, /web core, or /cli imports
from this module — confirmed by hack/check-tenant-boundary.sh, run in
CI. enterprise/ supplies tenant-scoped implementations of core's
already-shipped api/querylang/executor.SQLRunner/
SearchClient interfaces rather than core growing tenant awareness —
see /docs/phase-4-isolation-design.md for why.
Status
What's built and wired end-to-end. Verification status varies by
piece -- internal/audit was confirmed against a real Postgres earlier
in this phase's work; everything else below has real integration tests
written the same way (skipped unless a live database's connection
details are supplied via env var, same pattern throughout this package)
but they have not actually been run against a live database in this
environment -- see /docs/phase-4-runbook.md's verification-status
section for exactly what "not yet run" means here and why. Don't read
"has a test for this" as "this was confirmed to work."
internal/sessionissues/validates signed (HS256/JWT) tokens for both human sessions and/alerting'sRoleServicecredential.internal/authhandlerservesPOST /internal/authorize(the endpointapi/authz.HTTPAuthorizercalls) andGET /auth/features(the runtime-capability check/web's settings page reads).api's/queryand/dashboardsendpoints enforce RBAC viaauthz.RequireRole/RequireRoleOrService, nil-safe (no-op) whenENTERPRISE_AUTH_URLisn't configured -- matches Phase 0-3 behavior./alerting'squeryclientpresents aRoleServiceBearer token (API_SERVICE_TOKEN) when configured -- see/docs/phase-4-isolation-design.md'salerting↔apigap.sentryctlpresents$SENTRYCTL_TOKENas a Bearer credential on every request when set.internal/rbacstore: full CRUD overusers/tenants/tenant_memberships/data_sources(metadata/migrations/0017-0032).internal/tenantprovision: realCREATE DATABASE/CREATE USER/GRANTagainst ClickHouse. Its tests assert a tenant A user cannot read tenant B's database by fully-qualified name, and thatsystem.query_log/system.tables/SHOW DATABASESdon't leak across tenants either (task 2's finding was that the latter is version-dependent) -- not yet run against a live ClickHouse in this environment, see the note above.internal/chrunner: the tenant-scopedSQLRunner-- a per-tenant connection registry that resolves which tenant's ClickHouse connection to use from the authenticated identity in request context, never a parameter. Same adversarial probe, now through the actual production code path (chrunner.Registry.RunSQL, not just tenantprovision's raw grants).internal/audit.QueryAPILogger: the realapi/queryapi.AuditLoggerimplementation -- wired intoenterprise-api, no longernil.internal/loginhandler:GET /auth/oidc/login+GET /auth/oidc/callback-- the actual human login flow, previously entirely missing. Redirects to the configured IdP with CSRF-protection state in a short-lived cookie, exchanges the code, verifies the ID token viainternal/oidc, upserts ausersrow, resolves tenant/role from exactly onetenant_membershipsrow (refuses with a clear error on zero or multiple -- no tenant-picker UI yet), and issues a session cookie. This one genuinely is verified, unlike the ClickHouse pieces above:loginhandler_test.goruns the full flow against a real fake IdP (coreos/go-oidc's ownoidctestpackage, real RS256 signing and verification, no live database or Docker needed) and every test passes. Not yet tried against a real external IdP or a runningenterprise-authcontainer.cmd/enterprise-api: a second binary (alongsideapi/cmd/api, unchanged) importing bothapi's handler packages and the tenant-aware implementations above -- see its own doc comment for why this shape exists (enterprise → apiis the allowed import direction;apican never importenterprise/).-provision-tenant=<id>is the operator action that provisions ClickHouse and marks a tenant active, same "offline action, not a network endpoint" shape asenterprise-auth -mint-service-token.
Deliberately deferred, not half-built -- named explicitly rather than silently left out:
- SAML's login handler (the ACS endpoint) --
internal/samldoes the protocol mechanics (AuthnRequest generation, assertion validation); nothing calls it from an HTTP handler, followinginternal/ loginhandler's now-built OIDC pattern once someone builds it. - A tenant-picker UI/flow for an identity with more than one
tenant_membershipsrow --loginhandlerrefuses these logins outright rather than guessing (ErrMultipleMemberships). dashboard_permissionsCRUD (schema exists,metadata/migrations/0024; no caller reads per-resource grants yet --dashboards' handler enforces tenant-baseline role only, not the matrix's "(own/granted)" qualifier).internal/searchclient(the Tantivy-side sibling ofchrunner) --enterprise-apishares the single, un-tenant-scoped Tantivy index every deployment does today (api/searchclient.Dial, unchanged). See/docs/security/threat-model.md.- Any deployment-topology mechanism that actually routes traffic to
enterprise-apiinstead ofapi-- both binaries exist,docker-compose.ymlincludesenterprise-apiavailable but not wired intoweb's default base URL, and the Helm chart has no service for it at all yet.
Package layout
cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features endpoints, -mint-service-token
cmd/enterprise-api/ multi-tenant-aware alternative to api/cmd/api -- see its own doc comment
internal/tenant/ the ID type -- see its package doc comment before touching it
internal/oidc/ coreos/go-oidc wiring: discovery, login redirect, code exchange + ID token verification
internal/saml/ crewjam/saml wiring: SP setup, login redirect, response parsing/validation
internal/session/ issues/validates signed session + RoleService tokens
internal/authhandler/ POST /internal/authorize, GET /auth/features
internal/loginhandler/ GET /auth/oidc/login, GET /auth/oidc/callback -- the human login flow
internal/rbacstore/ users/tenants/tenant_memberships/data_sources CRUD (pgx against sentry_metadata)
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
internal/audit/ append-only, hash-chained query audit log, plus the
api/queryapi.AuditLogger adapter (queryapi_adapter.go)
internal/apiconfig/ enterprise-api's own env-var config
internal/config/ enterprise-auth's env-var config
Future additions: internal/searchclient, the OIDC/SAML login/callback
HTTP handlers, dashboard_permissions CRUD, and real deployment-topology
wiring for enterprise-api -- see "Status" above.
Why OIDC and SAML aren't hand-rolled
coreos/go-oidc (built on golang.org/x/oauth2) and crewjam/saml
handle token/assertion signature verification, XML signing, and the
protocol-level trust establishment — exactly the parts of an SSO
integration where a from-scratch implementation is the highest-risk
code in the whole feature. Both are well-established libraries, matching
this project's existing "boring, well-understood dependency" pattern
(clickhouse-go/v2, jackc/pgx/v5).
Building & testing
go build ./...
go vet ./...
go test ./...
internal/audit's real guarantees (the audit_writer grant
restriction, the immutability trigger, hash-chain correctness under
concurrency) can only be proven against a real Postgres — those
integration tests are skipped by default and only run with
AUDIT_TEST_POSTGRES_ADDR set:
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
-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
internal/rbacstore's tests are the same shape (real SQL, real
constraints), skipped unless RBACSTORE_TEST_POSTGRES_ADDR is set:
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
-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
internal/tenantprovision and internal/chrunner need a real
ClickHouse instead (they mount the repo root, not just enterprise/,
since internal/chrunner imports api/authz/api/querylang/executor
via go.mod's replace directives to ../api):
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
-e TENANTPROVISION_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
-e TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
golang:1.25-alpine go test ./internal/tenantprovision/... -v
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
-e CHRUNNER_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
-e CHRUNNER_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
golang:1.25-alpine go test ./internal/chrunner/... -v
Turning on auth enforcement for manual testing
Off by default (see "Status" above -- there's no login flow to issue a
human session yet). To exercise the RoleService path end to end:
docker compose up -d enterprise-auth
TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=alerting)
# api: set ENTERPRISE_AUTH_URL=http://enterprise-auth:8082 and restart
# alerting: set API_SERVICE_TOKEN=$TOKEN and restart
docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise/, not the repo root
Provisioning a tenant and running enterprise-api
docker compose build enterprise-api # context is the repo root, not enterprise/ -- see cmd/enterprise-api/Dockerfile
docker compose run --rm enterprise-api -provision-tenant=acme -display-name="Acme Corp"
docker compose up -d enterprise-api
curl -s http://localhost:8083/healthz
-provision-tenant creates the tenant/data_source rows in rbacstore if
they don't exist, provisions ClickHouse, persists the credentials, and
marks the tenant active -- refuses to run twice for the same tenant
(re-provisioning would either rotate a live credential or silently fail
to, see tenantprovision.ProvisionClickHouse's doc comment). web
still points at plain api by default (VITE_API_BASE_URL) --
pointing it at enterprise-api instead is a manual docker-compose.yml
edit today, not a supported flag.
Environment variables (enterprise-auth)
| Var | Default |
|---|---|
HTTP_LISTEN_ADDR |
:8082 |
POSTGRES_ADDR |
localhost:5432 |
POSTGRES_DATABASE |
sentry_metadata |
POSTGRES_USERNAME |
sentry |
POSTGRES_PASSWORD |
(empty) |
OIDC_ISSUER_URL |
(empty — OIDC discovery skipped if unset) |
OIDC_CLIENT_ID |
(empty) |
OIDC_CLIENT_SECRET |
(empty) |
OIDC_REDIRECT_URL |
(empty — must be <enterprise-auth base URL>/auth/oidc/callback, registered with the IdP) |
SAML_ENTITY_ID |
(empty) |
SAML_ACS_URL |
(empty) |
SAML_IDP_METADATA_URL |
(empty — presence only feeds GET /auth/features; not yet fetched/parsed) |
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 |
Environment variables (enterprise-api)
| Var | Default |
|---|---|
HTTP_LISTEN_ADDR |
:8083 |
CLICKHOUSE_ADDR |
localhost:9000 |
CLICKHOUSE_ADMIN_USERNAME |
default |
CLICKHOUSE_ADMIN_PASSWORD |
(empty) |
SEARCH_GRPC_ADDR |
localhost:50052 |
POSTGRES_ADDR |
localhost:5432 |
POSTGRES_DATABASE |
sentry_metadata |
POSTGRES_USERNAME |
sentry |
POSTGRES_PASSWORD |
(empty) |
AUDIT_WRITER_USERNAME |
audit_writer |
AUDIT_WRITER_PASSWORD |
(empty) |
ENTERPRISE_AUTH_URL |
(empty — RBAC becomes a no-op, but chrunner.Registry.RunSQL still refuses every query with no resolved tenant identity, so leaving this unset does not mean "open access," it means "every query fails") |
CORS_ALLOWED_ORIGIN |
* |
QUERY_TIMEOUT_SECONDS |
30 |