Extends the existing CheckIn RPC with a one-shot AgentCommand (restart only -- stop/uninstall need real per-platform OS service-manager integration and stay deliberately out of scope), delivered at-most-once: cleared the instant it's handed to the agent in a response, since a restarting agent's process is gone before it could ever confirm receipt. On restart, the agent flushes whatever's buffered, aborts its source task, and exits cleanly, relying entirely on the host's own service manager to bring it back up. Issuing a command is gated at RoleAdmin (stricter than config editing's RoleEditor) and logged into the same audit_log table Phase 7's AI interactions use, via a new agent_command event type. A real bug was found and fixed during live verification: the first implementation tried to atomically read-and-clear pending_command in a single INSERT...ON CONFLICT statement using a sibling CTE referenced only from RETURNING, on the assumption that Postgres evaluates every part of a WITH query against one pre-statement snapshot. That's wrong specifically for FOR UPDATE, which always reads the latest row version including one written earlier in the same statement -- confirmed empirically (a restart command was always coming back empty even when genuinely pending, so the agent never received it). Fixed by splitting into two real, ordered statements inside one explicit transaction. See /docs/agent-management-design.md's "Lifecycle commands" section.
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/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 fromtenant_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-livedsession.Manager.IssuePendingLogintoken -- rather than guessing; see "Tenant selection" below), 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.internal/searchclient: the Tantivy-side sibling ofchrunner-- implementsapi/querylang/executor.SearchClient, resolvingSearchRequest.tenant_id(new field,proto/sentry/search/v1/ search.proto) from the authenticated request identity, same fail-closed shape aschrunner.Registry.RunSQL. Paired withsearch/src/registry.rs'sIndexRegistry(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'scargo test/cargo clippy --all-targets -- -D warningsand this package'sgo testboth pass clean, no Docker or live database needed for either.Clientalso carries aTenantChecker(backed byrbacstore.TenantIsActive) sincesearch/src/registry.rs'sIndexRegistryopens-or-creates an index for any syntactically-validtenant_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 (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.
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-tenantwith{"tenant_id": "..."}-- re-derives the role for that specific tenant server-side (never trusts a client-supplied role, only checks the claimedtenant_idagainst 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
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). 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
Same shape for search's write-side active-tenant gate
(search/src/tenants.rs -- see /search/README.md's "Per-tenant
indices" section):
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.
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):
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):
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 |