Files
cairnobs/enterprise/README.md
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

565 lines
31 KiB
Markdown

# enterprise
**AGPLv3, same as core** (relicensed from a commercial-license stub as
of Phase 6 — see `/docs/compliance/license-audit-report.md`'s
"enterprise/ relicensing" section for the record of that decision and
what it means). 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. This is now an *architectural* boundary only, not a licensing
one: keeps core buildable and deployable with zero multi-tenant
mechanism present even though both sides carry the same license, and
preserves the network-trust-boundary design
`/docs/phase-4-isolation-design.md` describes (tenant identity is
resolved server-side, never taken from a request parameter). `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/session` issues/validates signed (HS256/JWT) tokens for both
human sessions and `/alerting`'s `RoleService` credential.
- `internal/authhandler` serves `POST /internal/authorize` (the endpoint
`api/authz.HTTPAuthorizer` calls) and `GET /auth/features`
(the runtime-capability check `/web`'s settings page reads).
- `api`'s `/query` and `/dashboards` endpoints enforce RBAC via
`authz.RequireRole`/`RequireRoleOrService`, nil-safe (no-op) when
`ENTERPRISE_AUTH_URL` isn't configured -- matches Phase 0-3 behavior.
- `/alerting`'s `queryclient` presents a `RoleService` Bearer token
(`API_SERVICE_TOKEN`) when configured -- see
`/docs/phase-4-isolation-design.md`'s `alerting``api` gap.
- `sentryctl` presents `$SENTRYCTL_TOKEN` as a Bearer credential on every
request when set.
- `internal/rbacstore`: full CRUD over `users`/`tenants`/
`tenant_memberships`/`data_sources` (`metadata/migrations/0017-0032`).
- `internal/tenantprovision`: real `CREATE DATABASE`/`CREATE USER`/
`GRANT` against ClickHouse. Its tests assert a tenant A user cannot
read tenant B's database by fully-qualified name, and that
`system.query_log`/`system.tables`/`SHOW DATABASES` don'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-scoped `SQLRunner` -- 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 real `api/queryapi.AuditLogger`
implementation -- wired into `enterprise-api`, no longer `nil`.
- `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 via `internal/oidc`,
upserts a `users` row, resolves tenant/role from `tenant_memberships`
(refuses with a clear error on zero rows; more than one starts a real
tenant-selection round trip -- `GET /auth/memberships` +
`POST /auth/select-tenant`, backed by a short-lived
`session.Manager.IssuePendingLogin` token -- rather than guessing; see
"Tenant selection" below), and issues a session cookie.
**This one genuinely is verified**, unlike the ClickHouse pieces above:
`loginhandler_test.go` runs the full flow against a real fake IdP
(`coreos/go-oidc`'s own `oidctest` package, 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 running
`enterprise-auth` container.
- `internal/searchclient`: the Tantivy-side sibling of `chrunner` --
implements `api/querylang/executor.SearchClient`, resolving
`SearchRequest.tenant_id` (new field, `proto/sentry/search/v1/
search.proto`) from the authenticated request identity, same
fail-closed shape as `chrunner.Registry.RunSQL`. Paired with
`search/src/registry.rs`'s `IndexRegistry` (Rust, opens a per-tenant
Tantivy index on demand). **Both genuinely verified** -- unlike the
ClickHouse pieces, Tantivy is an embedded library, so the isolation
probe (three tenants, shared search term, scoped search returns only
that tenant's document) actually ran: `search`'s
`cargo test`/`cargo clippy --all-targets -- -D warnings` and this
package's `go test` both pass clean, no Docker or live database
needed for either. `Client` also carries a `TenantChecker` (backed by
`rbacstore.TenantIsActive`) since `search/src/registry.rs`'s
`IndexRegistry` opens-or-creates an index for any syntactically-valid
`tenant_id` -- a real gap found while closing
`/docs/phase-4-isolation-design.md`'s verification-plan item 4: a
mid-provisioning tenant would otherwise get a silently-empty search
result instead of a refusal. Verified the same Docker-free way.
- `cmd/enterprise-api`: a second binary (alongside `api/cmd/api`,
unchanged) importing *both* `api`'s handler packages and the
tenant-aware implementations above -- see its own doc comment for why
this shape exists (`enterprise → api` is the allowed import direction;
`api` can never import `enterprise/`). `-provision-tenant=<id>` is the
operator action that provisions ClickHouse and marks a tenant active,
same "offline action, not a network endpoint" shape as
`enterprise-auth -mint-service-token`.
OIDC and SAML login are both now fully wired: `internal/loginhandler`
serves `GET /auth/oidc/login`+`GET /auth/oidc/callback` and
`GET /auth/saml/login`+`POST /auth/saml/acs`, converging on the same
upsert-user/resolve-tenant/issue-session path. Both are verified the
same way -- a real fake IdP with genuine cryptographic signing and
verification (`coreos/go-oidc`'s `oidctest` for OIDC,
`crewjam/saml/samlidp` for SAML), no Docker needed, every test in
`loginhandler_test.go`/`saml_test.go` passing including the full login
round trip and negative paths (bad state/`InResponseTo`, expired/missing
credential, no/multiple tenant memberships). Writing the SAML test
caught two real bugs in `internal/saml.ParseResponse`, both fixed:
missing `r.ParseForm()` before reading the POSTed `SAMLResponse` field,
and email-attribute matching that missed the standard LDAP "mail" OID
(`urn:oid:0.9.2342.19200300.100.1.3`) that IdPs send by default absent
an explicit `AttributeConsumingService` request for "email" -- exactly
what `samlidp`'s own default assertion builder does. Neither protocol
has been tried against a real external IdP or a running
`enterprise-auth` container -- see `/docs/phase-4-runbook.md` §3a/§3b.
`dashboard_permissions` is now wired end to end: `rbacstore/
dashboard_permissions.go` is the raw CRUD, `rbacstore/
dashboards_adapter.go`'s `DashboardPermissions` implements
`api/dashboards.PermissionStore` (the interface core defines and
carries as a nil-by-default field, same shape as
`queryapi.AuditLogger`), and `enterprise-api`'s `main.go` wires it in.
`api/dashboards`' handler now enforces the matrix's "(own/granted)"
qualifier: an Editor may edit/delete a dashboard (or its panels) they
created, or one where a grant raises their effective role to Editor;
managing grants themselves is stricter still -- creator or Admin/Owner
only, closing a self-escalation path where a granted-but-not-creator
Editor could otherwise extend their own access. `metadata/migrations/
0033_restrict_dashboard_permissions_role.sql` fixes a divergence
between 0024's actual CHECK constraint (allowed `role='admin'`, nullable
`granted_by`) and the design doc's schema (viewer/editor only,
`granted_by` required) found while wiring this up. Verified against a
fake `PermissionStore` in `api/dashboards/handler_test.go` (the full
own/granted/admin/creator matrix, including the granted-editor-cannot-
manage-grants regression); real integration tests exist in
`rbacstore_test.go` but haven't run against a live Postgres in this
environment, same disclosed gap as the rest of this package's
Postgres-backed pieces.
## Tenant selection (multi-membership identities)
Both the backend protocol for choosing a tenant and the frontend page
that calls it (`web/src/routes/select-tenant`) are now built. When
`resolveIdentity` finds more than one `tenant_memberships` row for a
logged-in identity, `finishLogin`
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
this design caught in its own tests: a shared JSON key would have let a
full session token double as a pending login) as a
`sentry_pending_login` cookie (`Path=/auth`) and redirects to
`SELECT_TENANT_REDIRECT_URL` (defaults to
`{POST_LOGIN_REDIRECT_URL}/select-tenant`) instead of completing the
login. From there:
- `GET /auth/memberships` -- lists the pending identity's tenants
(`tenant_id`, `tenant_display_name`, `role`) to choose between.
- `POST /auth/select-tenant` with `{"tenant_id": "..."}` -- re-derives
the role for that specific tenant server-side (never trusts a
client-supplied role, only checks the claimed `tenant_id` against the
identity's actual memberships, refusing with 403 otherwise), issues
the real session cookie, and responds with
`{"redirect_url": "..."}` for the caller to navigate to -- JSON, not a
redirect, since this is a POST/fetch call a frontend page should
control the navigation for itself.
Verified with the same real-fake-IdP tests as the rest of this package
(`loginhandler_test.go`/`saml_test.go`): the full login -> pending
cookie -> `GET /auth/memberships` -> `POST /auth/select-tenant` -> real
session round trip, plus negative paths (missing/expired/wrong-type
pending cookie, a `tenant_id` outside the identity's actual
memberships, a real session token rejected when presented as a pending
login).
The frontend side needed two things `web` didn't have: session/cookie-
aware requests (`$lib/api.ts`'s `listMemberships`/`selectTenant`, both
`fetch(..., {credentials: 'include'})`) and CORS that actually allows a
credentialed cross-origin request -- `httpserver.WithCredentialedCORS`
(new, in `api/httpserver`, next to the plain `WithCORS` every other
service in this repo uses), wired in by this binary's `main.go` and
configured via the new `CORSAllowedOrigin` field
(`CORS_ALLOWED_ORIGIN`, defaulting to `PostLoginRedirectURL` --
`web`'s own origin is exactly what needs credentialed access here).
Browsers categorically refuse to honor `Access-Control-Allow-Origin: "*"`
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.
Two other things once named as deferred here are built too: ingest
write-routing, both ClickHouse (see "Ingest write-routing (ClickHouse)"
below) and Tantivy (`/search/README.md`'s "Per-tenant indices" section
-- needed no code in this module at all, since `search`'s
`IndexRegistry` already lived in AGPL core); and deployment-topology
routing (does traffic actually reach `enterprise-api` instead of
`api`), now a single-flag choice in both `deploy/helm/sentry` and
`docker-compose.yml` (`enterprise.enabled` / `COMPOSE_PROFILES`), see
CLAUDE.md.
## Ingest tenant identity
`ingest` (AGPL core) gained an optional `TenantResolver`
(`ingest/internal/grpcserver`) -- nil by default, the same "off unless
configured" shape as every other optional integration point in this
codebase. When `ENTERPRISE_AUTH_URL` is set, `PushBatch` requires an
`authorization: Bearer <token>` gRPC metadata entry on every call,
resolves it via a new `POST /internal/authorize-ingest` endpoint on
*this* service (`internal/authhandler`, backed by a new
`ingest_credentials` table in `internal/rbacstore` -- only a SHA-256
hash of the token is ever stored), and attaches the resolved tenant ID
to every record as a `tenant_id` Kafka message header before producing
it. Fail-closed: once a resolver is configured, a missing or invalid
credential refuses the whole batch, never falls back to "no tenant."
Mint a credential with `-create-ingest-credential-tenant=<id>` (prints
the plaintext token exactly once -- see `ingest_credentials`' migration
comment for why it can't be recovered again, only reissued);
`-list-ingest-credentials-tenant=<id>`/`-revoke-ingest-credential=<id>`
manage existing ones. `ingest`'s own HTTP client
(`ingest/internal/tenantresolver.HTTPResolver`) is the piece that
actually calls `/internal/authorize-ingest` -- never an `enterprise/`
import (`ingest` is AGPL core), same "network boundary, not import
boundary" shape `api/authz.HTTPAuthorizer` already uses for the query
path.
## Ingest write-routing (ClickHouse)
`enterprise/cmd/enterprise-ingest` is `ingest -mode=consumer`'s multi-
tenant alternative -- same "second binary" shape as `enterprise-api`
next to `api/cmd/api` (AGPL core must never import `enterprise/`, so the
tenant-aware wiring has to live in a binary that imports *into* core,
not the reverse). It reuses `ingest/consumer.Consumer`'s exact flush
loop unchanged, swapping in `enterprise/internal/chwriter.Registry` --
chrunner's write-side counterpart -- as the writer: one fully separate
`*ingest/clickhousewriter.Writer` (and the `driver.Conn` under it) per
tenant, built once at startup from `rbacstore.
ListProvisionedDataSources` (the same source of truth `chrunner` already
uses for reads). `WriteBatch` groups a Kafka batch's records by their
`tenant_id` tag and writes each tenant's group through its own
dedicated connection, refusing the whole call (matching `ingest/
consumer`'s existing all-or-nothing batch contract -- no offsets commit,
the batch redelivers) if any record is untagged or tagged with a tenant
that isn't provisioned. `ingest/consumer` and `ingest/clickhousewriter`
moved out of `internal/` for this -- same Go compiler-enforced
visibility reasoning as every other package this phase moved out of
`internal/` for a cross-module import (see `ingest/README.md`'s "Multi-
tenant write-routing" section).
The writer map isn't frozen at startup anymore: `Registry.
StartRefreshing` (called from `cmd/enterprise-ingest/main.go` right
after construction) spawns a goroutine that re-lists data sources every
minute and reconciles the map -- opens a connection for a newly-active
tenant, closes and removes one no longer active. This closes the same
staleness gap `search/src/tenants.rs`'s `ActiveTenantTracker` closes on
the Tantivy side (see `/search/README.md`'s "Per-tenant indices"
section), at the same one-minute interval, so a deprovisioned tenant
loses write access on both storage engines within roughly the same
window instead of ClickHouse's writer surviving until the next
`enterprise-ingest` restart. A refresh failure (rbacstore unreachable,
or one tenant's new connection failing to open) logs and leaves the
existing map untouched for that tick, the same last-known-good posture
the Rust tracker uses.
A real bug was found and fixed while wiring this up:
`tenantprovision.ProvisionClickHouse` originally granted a tenant's
ClickHouse user `SELECT` only -- correct for `chrunner`'s query path,
but it would have made every real per-tenant write from `chwriter` fail
with a permission error, since it's the *same* credential used for
both. Fixed by granting `SELECT, INSERT` (not a second, separate
write-only credential -- there's no cross-tenant boundary crossed by
also granting INSERT within a tenant's own database, so one credential
for both directions is the simpler, still-correctly-scoped choice).
A real multi-tenant deployment runs `ingest -mode=server` (agent-facing,
tags records, unchanged) alongside `enterprise-ingest` (consumer,
per-tenant writes) *instead of* `ingest -mode=consumer` -- see `deploy/
helm/sentry`'s `ingest.requireTenantCredential` value (gates both the
credential-validation requirement and this mode split together, since
write-routing is only meaningful once records actually carry a
tenant_id to route on) and `docker-compose.yml`'s `enterprise-ingest`
service (a simpler opt-in there -- true `-mode=server`/`-mode=consumer`
exclusivity isn't wired in compose, a disclosed local-dev-only gap; see
that service's own comment).
Verified: `enterprise/internal/chwriter`'s fail-closed paths (empty/
unknown `tenant_id`, and now a failed refresh keeping the last-known-good
map) run genuinely without Docker (constructing a `Registry` directly,
bypassing `New`, which is the only part that would dial ClickHouse); the
actual per-tenant write-isolation probe
(`TestRegistryWritesEachTenantToItsOwnDatabase`), the `tenantprovision`
INSERT-grant regression test, and refresh's add/remove reconciliation
(`TestRefreshAddsNewlyActiveTenant`/`TestRefreshRemovesNoLongerActiveTenant`)
are real integration tests against a live ClickHouse, same
`CHWRITER_TEST_CLICKHOUSE_ADDR`/`TENANTPROVISION_TEST_CLICKHOUSE_ADDR`
convention as every other ClickHouse-backed test this phase -- not run
against a live database in this environment.
## Package layout
```
cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features/authorize-ingest/active-tenants endpoints, -mint-service-token, -create-tenant, -grant-membership-*, -revoke-membership-*, -list-memberships-tenant, -transfer-owner-*, -create-ingest-credential-tenant, -list-ingest-credentials-tenant, -revoke-ingest-credential
cmd/enterprise-api/ multi-tenant-aware alternative to api/cmd/api -- see its own doc comment
cmd/enterprise-ingest/ multi-tenant-aware alternative to ingest -mode=consumer -- 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,callback} + GET /auth/saml/login + POST /auth/saml/acs -- the human login flow
internal/rbacstore/ users/tenants/tenant_memberships/data_sources/dashboard_permissions CRUD (pgx against sentry_metadata)
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
internal/tenantcrd/ syncs -provision-tenant's real result into deploy/operator's Tenant CRD (K8s dynamic client, no cluster needed to test)
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
internal/chwriter/ tenant-scoped ingest/consumer.chWriter -- chrunner's write-side counterpart
internal/searchclient/ tenant-scoped api/querylang/executor.SearchClient
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/ingestconfig/ enterprise-ingest's own env-var config
internal/config/ enterprise-auth's env-var config
```
`ingest/internal/tenantresolver` (AGPL core, not enterprise/, since
ingest must never import enterprise/) is the client side of `internal/
authhandler`'s new `POST /internal/authorize-ingest` -- see "Ingest
tenant identity" above.
Per-tenant write-routing for ingest is built for both ClickHouse (this
module's `cmd/enterprise-ingest` + `internal/chwriter`, see "Ingest
write-routing (ClickHouse)" above) and Tantivy (`search/src/consumer.rs`
+ `search/src/registry.rs`, entirely in AGPL core -- see
`/search/README.md`'s "Per-tenant indices" section, since Tantivy's lack
of a grant system meant there was never an import-boundary reason to put
any of it here).
## 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
```sh
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:
```sh
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=cairnobs-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:
```sh
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=cairnobs-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`):
```sh
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
-e TENANTPROVISION_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
-e TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD=cairnobs-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=cairnobs-dev-only \
golang:1.25-alpine go test ./internal/chrunner/... -v
```
## Turning on auth enforcement for manual testing
Off by default (see "Status" above). To exercise the `RoleService` path
end to end:
```sh
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
```
Same shape for `search`'s write-side active-tenant gate
(`search/src/tenants.rs` -- see `/search/README.md`'s "Per-tenant
indices" section):
```sh
docker compose up -d enterprise-auth
SEARCH_TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=search)
# search: set ENTERPRISE_AUTH_URL=http://enterprise-auth:8082 and
# ENTERPRISE_AUTH_SERVICE_TOKEN=$SEARCH_TOKEN, then restart -- unlike
# alerting/api above, this doesn't turn on request-level auth
# enforcement anywhere; it only gates which tenant_ids search will
# write-route into their own index, refusing anything not in
# GET /internal/active-tenants' response.
```
```sh
docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise/, not the repo root
```
## Bootstrapping a tenant and its first human user
`-create-tenant`/`-grant-membership-*` are `enterprise-auth` operator
flags, same "offline action gated by access to enterprise-auth's own
environment, not a network-reachable endpoint" shape as
`-mint-service-token` -- deliberately not an authenticated HTTP admin
API, which would have to solve "who's allowed to create the very first
tenant/membership" itself. Replaces what used to be a manual `psql`
dance (see `/docs/phase-4-runbook.md` §3a's history if you're wondering
why old references to it still show up in git blame):
```sh
docker compose run --rm enterprise-auth -create-tenant=acme -display-name="Acme Corp"
# Log in once via /auth/oidc/login or /auth/saml/login -- it fails with
# "no tenant membership" (403), but UpsertUserBySSO already created the
# users row by then, which -grant-membership-user-email needs.
docker compose run --rm enterprise-auth \
-grant-membership-tenant=acme -grant-membership-user-email=[email protected] -grant-membership-role=owner
# See who's actually in a tenant, and take access away again:
docker compose run --rm enterprise-auth -list-memberships-tenant=acme
docker compose run --rm enterprise-auth \
-revoke-membership-tenant=acme -revoke-membership-user-email=[email protected]
# Hand ownership to someone else -- the previous owner is downgraded to
# admin, not removed, so this doesn't need a -revoke-membership-* first:
docker compose run --rm enterprise-auth \
-transfer-owner-tenant=acme -transfer-owner-user-email=[email protected]
```
`-create-tenant` only touches `rbacstore` -- pair with `enterprise-api
-provision-tenant` (below) for a tenant to actually be able to run
queries, not just log in. `role=owner` also calls `SetOwner`, since a
tenant's Owner is a dedicated `tenants.owner_user_id` column, not just
the highest `tenant_memberships` role -- but only for a tenant's *first*
owner assignment (it refuses if a *different* owner already exists, per
`rbacstore.TransferOwner`'s doc comment). `-revoke-membership-*` refuses
to revoke a tenant's current Owner for the same reason `tenants.
owner_user_id` can only ever name one user --
`-transfer-owner-tenant`/`-transfer-owner-user-email` is the real
handoff: `rbacstore.TransferOwner` atomically downgrades the current
owner to `admin`, promotes the new owner, and updates
`tenants.owner_user_id`, all in one transaction (the first this package
uses -- every other mutation here is a single independent statement,
but leaving `owner_user_id` and `tenant_memberships` disagreeing mid-
operation is exactly the inconsistent state `RevokeMembership`'s doc
comment already worries about). Changing a non-Owner role is just
re-running `-grant-membership-*` with a different
`-grant-membership-role` (`SetMembership`'s upsert already supports
it). `dashboard_permissions` grants have no `enterprise-auth` flag and
don't need one -- `sentryctl dashboards permissions list|grant|revoke`
covers them over the HTTP endpoints `api/dashboards`' handler already
exposes (`PUT`/`DELETE /dashboards/{id}/permissions/{userId}`,
`GET .../permissions`), see `/cli/README.md`.
## Provisioning a tenant and running `enterprise-api`
`api`/`enterprise-api` are mutually exclusive in `docker-compose.yml`,
gated behind `COMPOSE_PROFILES` (`.env` checks in `single-tenant`, i.e.
plain `api`, as the zero-config default -- mirrors Helm's
`enterprise.enabled` flag). `-provision-tenant` itself doesn't bind a
port, so it runs fine regardless of the active profile; actually
serving traffic on `enterprise-api` needs the `enterprise` profile
active, since it now binds the same host port (8080) plain `api` does
(it gets a `default.aliases: [api]` network alias too, so `alerting`'s
`API_QUERY_URL`/`web`'s `VITE_API_BASE_URL` need zero changes either
way):
```sh
COMPOSE_PROFILES=enterprise docker compose build enterprise-api # context is the repo root, not enterprise/ -- see cmd/enterprise-api/Dockerfile
COMPOSE_PROFILES=enterprise docker compose run --rm enterprise-api -provision-tenant=acme -display-name="Acme Corp"
COMPOSE_PROFILES=enterprise docker compose up -d enterprise-api
curl -s http://localhost:8080/healthz
```
`docker-compose.yml` has no Kubernetes cluster to sync into, so
`TENANT_CRD_NAMESPACE` is never set here -- `-provision-tenant`'s
`internal/tenantcrd` sync step is a documented no-op in this deployment
shape, same as everywhere else this codebase has an "off unless
configured" optional dependency. It only does anything in a real
cluster with `deploy/helm/sentry`'s `tenantOperator.enabled=true` -- see
`/deploy/helm/sentry/README.md`'s "Trying the two-tenant example."
`-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).
## 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 — 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 |
| `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; `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`)
| 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` |