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.
565 lines
31 KiB
Markdown
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` |
|