Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment

RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+89 -22
View File
@@ -1,9 +1,13 @@
# Sentry Architecture
> **Status:** Draft, Phase 0 scope. Written from the project constraints and
> task list at kickoff, not transcribed from a pre-existing spec. Treat as a
> starting point to correct, not a settled design — flag anything that
> doesn't match your intent before implementation leans on it further.
> **Status:** Updated through Phase 4. The component map/diagram below is
> still the Phase 0 request path (agent → ingest → ClickHouse → api →
> web) — it was never redrawn for the full-text search, dashboards/
> alerting, or enterprise/ additions; see each phase's runbook
> (`/docs/phase-N-runbook.md`) for what was actually verified when it
> shipped. The component responsibilities table and the sections below
> the diagram are kept current. Phase 0's original framing ("draft,
> correct as needed") still applies to anything not yet built.
## Mission
@@ -53,44 +57,107 @@ one instead of deferring it, and keeps Kafka credentials off the edge agent.
schema; schema-on-read fallback for unstructured/raw text that doesn't fit
the structured columns (captured via the `Map` column and/or a raw
passthrough field).
- **Postgres** (Phase 3) holds control-plane config only — dashboards,
panels, notification targets, alert rules/state, delivery log, and
(Phase 4) tenants/users/tenant_memberships/audit_log. Never log data;
ClickHouse/Tantivy stay the only place a log record itself lives. See
`/docs/phase-3-dashboard-design.md` for why ClickHouse's MergeTree
family isn't a fit for this (no real row-level locking/transactional
read-modify-write).
This split is not to be changed without discussion — see CLAUDE.md.
## Component responsibilities (Phase 0)
## Component responsibilities
| Component | Responsibility |
|---|---|
| `agent` (Rust, musl) | Tail a log file or read journald; parse RFC 5424 syslog with raw passthrough fallback; batch; ship via gRPC/mTLS to `ingest`. |
| `proto` | Shared `.proto` contracts for the agent↔ingest gRPC service, versioned independently of either component. |
| `agent` (Rust, musl) | Tail a log file or read journald; parse RFC 5424 syslog with raw passthrough fallback; batch; ship via gRPC/mTLS to `ingest`. Windows (ETW/Event Log) code exists but is unverified on real Windows hardware — see `/agent/README.md`. |
| `proto` | Shared `.proto` contracts for agent↔ingest and api↔search gRPC, versioned independently of either side. |
| `transport` | Redpanda docker-compose + topic provisioning scripts. No application code. |
| `ingest` (Go) | gRPC server accepting agent connections; produces normalized OTel-log-like records to Redpanda; separate consumer reads from Redpanda and batch-writes to ClickHouse. |
| `ingest` (Go) | gRPC server accepting agent connections; produces normalized OTel-log-like records to Redpanda; separate consumer reads from Redpanda and batch-writes to ClickHouse. No tenant concept — every record lands in the one shared `logs` table regardless of source (see "Tenant isolation" below). |
| `storage` | ClickHouse schema migrations + docker-compose for local/homelab. |
| `api` (Go) | gRPC + REST gateway. Phase 0: one crude `POST /query` endpoint, SELECT-only, proxying to ClickHouse. Real SPL-like query layer is Phase 2. |
| `web` (SvelteKit) | Single page: SQL text box, submit, results table. No auth, no styling polish. |
| `cli` (`sentryctl`) | Stub. Single `ping` command for now. |
| `deploy` | Helm charts, k8s manifests. Stubbed in Phase 0; docker-compose is the real local/dev path. |
| `search` (Rust, Phase 1) | Consumes the same Redpanda topic `ingest` does (own offset tracking), builds a Tantivy full-text index over `message`, serves matches over gRPC. One shared index for every tenant today — see "Tenant isolation" below. |
| `api` (Go) | gRPC + REST gateway. `POST /query` compiles pipe-syntax or raw SQL to one IR, executed across ClickHouse/Tantivy (`/docs/query-language-design.md`). `internal/dashboards` is CRUD only — panel query execution happens client-side, reusing `/query`. `internal/authz` (Phase 4) enforces RBAC via a network call to `enterprise-auth`, never an import. |
| `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). |
| `enterprise` (Go, commercial license, Phase 4) | SSO (OIDC/SAML protocol mechanics), RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), and `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`). Never imported by core — see "Licensing boundary" below. Does **not** yet include per-tenant ClickHouse/Tantivy connection routing or the OIDC/SAML login HTTP handlers — see `/docs/security/threat-model.md`. |
| `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling enterprise-licensed components. |
| `cli` (`sentryctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$SENTRYCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). |
| `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. |
## Tenant isolation model (Phase 4)
Full design rationale: `/docs/phase-4-isolation-design.md`. Full honest
accounting of what's actually enforced vs. designed-only:
`/docs/security/threat-model.md` — read that before assuming any claim
below holds for log data specifically.
**As designed:** one dedicated ClickHouse database + narrowly-granted
user per tenant (never the shared `default`/admin credential), one
dedicated Tantivy index directory per tenant, `system.*` access revoked
per tenant, connections resolved from an immutable per-tenant map (never
a shared pool with session-level `USE`). Isolation lives at the
**connection layer** — every query, compiled or raw SQL, is forced
through a tenant-scoped connection the database's own access control
enforces — not at the query-compiler layer, since Phase 2's raw-SQL
escape hatch is opaque to any compiler-injected filter.
**As built, through Phase 4 task 8:**
- Role-based access control (`api/internal/authz`) is live on `/query`
and `/dashboards`, resolved via `enterprise-auth` over HTTP.
- Control-plane tenant scoping is live for dashboards
(`api/internal/dashboards`'s store filters every query by the
authenticated identity's tenant, never a client-supplied field).
- The `alerting``api` service-identity gap (task 2's finding) is
closed: a `RoleService` credential, distinct from every human role.
- **The connection-layer isolation itself — the actual design above —
is not built.** `api/internal/querylang/executor.SQLRunner`/
`SearchClient` and `search`'s gRPC service carry no tenant field
anywhere. There is one shared ClickHouse connection and one shared
Tantivy index for every tenant. RBAC controls *who* can run a query;
nothing yet controls *what data* that query can see.
- `deploy/operator`'s `Tenant` CRD manages only the K8s-side artifact (a
credential Secret) — it doesn't call ClickHouse or provision anything
ClickHouse-side. `enterprise/internal/tenantprovision` (the piece that
would) is unbuilt.
Building `enterprise/internal/chrunner` + `internal/searchclient` (the
tenant-scoped implementations of the two interfaces above) and wiring
them into `api/internal/queryapi.Handler` in place of the single shared
connection `api/cmd/api/main.go` opens today is the single largest
remaining gap between this system and the isolation model it was
designed to have.
## Licensing boundary
AGPLv3 for core + agents. Enterprise features (SSO, multi-tenancy,
compliance) live under `enterprise/` (not yet created — out of scope for
Phase 0) under a commercial license stub. AGPL code must never import from
`enterprise/`. No enterprise-gated code exists yet in this repo; this
section documents the boundary so nothing added later crosses it by
accident.
AGPLv3 for core + agents. Enterprise features (SSO, RBAC storage, audit
logging) live under `enterprise/` (commercial license stub, added
Phase 4). AGPL code must never import from `enterprise/` — enforced in
CI by `hack/check-tenant-boundary.sh`, which greps every build for the
import edge. Where core needs a decision only `enterprise/` can make
(is this request authorized, what SSO is configured), it calls
`enterprise-auth` over plain HTTP instead
(`api/internal/authz.HTTPAuthorizer`, `web`'s `GET /auth/features`) —
the same "network boundary, not import boundary" shape `/alerting``api`
already used before `enterprise/` existed.
## Non-negotiables carried from CLAUDE.md
- Rust agent: statically linked musl, `x86_64-unknown-linux-musl` and
`aarch64-unknown-linux-musl`, no glibc runtime deps.
- Windows support (Phase 1+) via native ETW/Event Log API, not WSL.
- Windows support via native ETW/Event Log API, not WSL — designed
(Phase 1) but still unverified on real Windows hardware.
- Every UI action maps to a documented REST/gRPC call — no UI-only logic.
- Pinned stack (see CLAUDE.md table) — no substitutions without discussion.
## Explicitly out of scope for Phase 0
## Explicitly out of scope (current, Phase 4)
Windows agent, alerting, dashboards, multi-tenancy, Tantivy full-text
search, the real SPL-like query language, enterprise module code.
Per `/CLAUDE.md`'s Phase 4 non-goals and `/docs/security/threat-model.md`:
deny-override permission grants, a data retention/deletion policy for
deprovisioned tenants, general multi-cluster orchestration in `/deploy`,
and any defense against a privileged ClickHouse/Postgres administrator —
every isolation and audit-integrity guarantee here is a structural
defense against application-layer bugs, not an operational control.
## Open questions for you to resolve
+317
View File
@@ -0,0 +1,317 @@
# Tenant isolation design
> **Status:** Design, awaiting sign-off. Task 2 of Phase 4 — the highest-
> risk decision in the project so far, per explicit instruction: stop
> here before any code is written. This document was pressure-tested by
> an adversarial design review before being written up (not just
> reasoned through once and accepted) — several of the "required design
> elements" below exist specifically because that review found concrete
> bypass scenarios in an earlier draft, not because they're generically
> prudent. If implementation reveals this design is wrong somewhere, fix
> this doc in the same change — same discipline as every prior phase's
> design docs.
## Why this design, in one paragraph
Phases 03 are entirely single-tenant with zero authentication anywhere.
Phase 4 needs to isolate tenant data with a security-review-credible
guarantee, and the central fact shaping everything below is that Phase
2's query language has a raw-SQL escape hatch that is deliberately
*opaque* — never parsed, never validated against a schema
(`/docs/query-language-design.md`). That single fact rules out row-level
filtering (a `tenant_id` column plus a compiler-injected `WHERE` clause)
as the *sole* isolation mechanism: a filter the compiler injects
categorically cannot apply to a query the compiler never parses. So the
real isolation boundary has to live one layer down, at the database
connection itself — a tenant's ClickHouse user simply has no grant to
read another tenant's database, and no application code, compiled query
or hand-written SQL, can change that. Everything else in this document
is either implementing that connection-layer boundary correctly or
closing a gap the adversarial review found in a naive version of it.
## Module placement: `enterprise/` only, confirmed with the project owner
The tenant-isolation mechanism described here — per-tenant ClickHouse
database/user, per-tenant Tantivy index, the `TenantID` plumbing —
ships entirely in `enterprise/`, not AGPL core. Core (`/api`,
`/alerting`, `/web`) stays genuinely single-tenant: no multi-tenant
mechanism present at all, not merely a missing management UI on top of
otherwise-functional isolation. This was an explicit choice put to the
project owner rather than assumed, because CLAUDE.md's licensing
boundary text names multi-tenancy as enterprise-gated, and a
"mechanism in core, feature in enterprise" split would have let a
sufficiently motivated self-hosting AGPL user wire up real isolation
without ever touching `enterprise/` — undermining that boundary in
substance even while technically respecting the AGPL/commercial import
graph. Confirmed: enterprise-only.
Mechanically, this works because `api/internal/querylang/executor`
already defines the seam Phase 2 needs regardless of tenancy:
```go
type SQLRunner interface {
RunSQL(ctx context.Context, sql string) (*Result, error)
}
type SearchClient interface {
Search(ctx context.Context, query string, limit uint32) ([]string, error)
}
```
`enterprise/` supplies tenant-scoped implementations of these same
core-defined interfaces — Go interfaces don't require an import edge
from core to enterprise, only enterprise importing core's *interface
types*, the allowed direction. Core's `querylang`/`executor` packages
need zero changes for tenancy; `ChRunner` (today's single-connection
implementation, `api/internal/querylang/executor/chrunner.go`) stays
exactly as it is for single-tenant deployments, and `enterprise/`
provides an alternate implementation for multi-tenant ones.
## A framing correction, stated plainly rather than built around
The original task language asks for "compile-time... structurally
impossible to bypass" enforcement in the query compiler. Given the
raw-SQL passthrough above, that specific phrasing isn't achievable in
any module — there's no parse step to inject a filter into. The
achievable, honest version: **every code path, compiled query or raw
SQL, is forced through a tenant-scoped connection that the database's
own access-control system enforces.** The structural guarantee is at
the connection/index layer, not the compiler layer. This document's
"required design elements" are what make that connection-layer
guarantee actually hold under concurrency, partial failure, and
ClickHouse's own default-permissive corners — not decoration on top of
an already-sufficient row filter.
## ClickHouse: database-per-tenant, grant-enforced
One ClickHouse database + one dedicated, narrowly-granted ClickHouse
user per tenant, on the shared cluster by default. A tenant can later be
pinned to dedicated cluster nodes (Phase 4 task 6, a deployment-topology
decision) for large/regulated customers — that changes *where* a
tenant's database physically runs, not this model. `enterprise/` holds a
small map of per-tenant `*ChRunner`s — today's `chrunner.go` shape (one
`driver.Conn`, `Auth.Database` fixed at construction) is already exactly
right, this just needs N of them instead of one. **No `tenant_id`
column on `logs`**: isolation is a connection-level property, so there
is nothing else in a tenant's own database to filter or leak through a
missed `WHERE` clause.
### Required design elements
Each of these closes a specific bypass the adversarial review found in
a naive version of "just give each tenant a database":
**1. No tenant traffic ever authenticates as ClickHouse's `default`
user.** Today's `docker-compose.yml` sets `CLICKHOUSE_PASSWORD` on the
implicit `default` user for the whole stack — a Phase 03-appropriate
shortcut that must not carry into tenant-scoped connections. `default`
(or an equivalent broad-access account) is reserved for
migrations/provisioning/ops only, never handed to a request-serving
code path.
**2. `system.*` access is explicitly revoked from every tenant user,
not left at whatever ClickHouse's default template grants.**
`system.query_log` records every query's full text by default, and is
broadly readable unless explicitly revoked — so even with per-database
row isolation working *perfectly*, a tenant able to read
`system.query_log` can see other tenants' query text: predicate values,
field names, sometimes literally sensitive data embedded in a `WHERE`
clause. `SHOW DATABASES`/`system.tables` visibility being properly
grant-scoped is version-dependent on `access_management` actually being
engaged for the account, not the default `users.xml`-style setup. This
is not assumed from documentation — it's verified by an adversarial
integration test (Phase 4 task 8) that, as a tenant-scoped user,
attempts `SELECT * FROM system.query_log`, `SELECT * FROM
system.tables`, `SHOW DATABASES`, and a fully-qualified cross-tenant
`SELECT * FROM <other_tenant_db>.logs`, asserting each is denied or
empty. Provisioning (`enterprise/internal/tenantprovision`) explicitly
revokes/never-grants `system.*` as part of creating a tenant user.
**3. Per-tenant connections are fully separate `driver.Conn`/pool
objects — never one shared pool with session-level `USE tenant_x`.** A
shared-pool-plus-`USE` implementation is a real concurrency bug, not a
theoretical one: a connection recycled between tenants mid-flight can
interleave a `USE` statement for tenant A with a query that actually
executes against tenant B's still-live session state, depending on how
`clickhouse-go/v2` recycles connections under load. This design
mandates N fully separate pools, resolved fresh per request — as a
local variable inside the request-handling goroutine, never cached in a
mutable struct field shared across goroutines — from an
immutable-after-startup `map[TenantID]*ChRunner`. Growing or shrinking
that map (tenant on/offboarding) happens by replacing the map wholesale
(copy-on-write), never by mutating it in place under concurrent readers.
**4. Provisioning is ordered, idempotent, and gated on an explicit
`active` state.** Sequence: `CREATE USER IF NOT EXISTS` with a
zero-privilege base role (not ClickHouse's implicit default profile) →
`GRANT` narrow, tenant-database-scoped access → only *then* mark the
tenant `active` in the `tenants` table (Postgres, `/metadata`). Every
tenant-resolution code path refuses to serve a tenant not in `active`
state, checked against that table server-side — never inferred from "a
connection happened to succeed," which would happily serve traffic
during a half-finished provisioning run. A crashed/retried provisioning
job must not leave a *broader*-than-intended grant live during the
retry window; the ordering above (narrow grant strictly before
`active`) is what prevents that. Deprovisioning must not leave a live
cached connection usable past a revoked grant on some ClickHouse
versions dropping a user doesn't terminate already-open sessions — so
offboarding either explicitly terminates sessions for the tenant's user
or relies on a bounded max lifetime for cached per-tenant connections
(not indefinite reuse).
## Tantivy: index-per-tenant
Same underlying reasoning as ClickHouse, for a sharper reason: Tantivy
has no grant system at all, so "one shared index with a tenant-tagged
field, filtered at query time" would have *zero* structural backing —
purely conventional, exactly the "convention, not structural" failure
mode this whole design exists to avoid. `search`'s current shape (one
`Arc<SearchIndex>` opened once at startup — `search/src/index.rs`,
`search/src/main.rs`) becomes a registry: a `HashMap<TenantID,
Arc<SearchIndex>>` (an LRU if tenant count ever grows large enough that
holding every index open simultaneously is wasteful — not needed for
Phase 4's initial scale), each index a separate directory under a
shared volume, opened or created on demand and resolved only from the
tenant context `enterprise/`'s trusted caller establishes.
`search.proto`'s `SearchRequest` gains a tenant field, populated
exclusively by `enterprise/`'s tenant-scoped `SearchClient`
implementation — never read from anything a remote/external client
supplies.
## The `alerting` ↔ `api` gap
Found by the adversarial review, not present in the first draft — real,
not hypothetical, and it has to be resolved as part of this sign-off
because it shapes Phase 4 task 5's design directly.
**Today's actual behavior**: `alerting`'s evaluator
(`alerting/internal/evaluator/evaluator.go`) claims due rules across
*all* tenants in a single `rulestore.ClaimDueRules` call, then for each
one calls `api`'s `POST /query` via `internal/queryclient/client.go`
with just `{query, language}` — no tenant field, no authentication, at
all, today.
**Why this matters for isolation specifically**: `alerting` is a
machine calling on a schedule, not a human with a session — there is no
session to derive a tenant context from the way a browser request has
one. The tempting, wrong fix is adding a `tenant_id` field to the
`/query` request, populated from the rule's own `TenantID` (which
`rulestore.RuleWithState` already carries after Phase 4's schema
additions). That is *exactly* the client-suppliable tenant identifier
this entire design exists to prevent — `alerting`'s HTTP surface is
unauthenticated today, so anything able to reach it (or spoof a call to
`api` shaped like one) could request any tenant's data by setting that
field.
**The correct fix**, scoped into Phase 4 task 5: a distinct **service
identity** for `alerting` — a signed service token or mTLS client
certificate, not a human session — that `api`/`enterprise/` map to "may
execute the query belonging to rule X," where rule X's tenant is looked
up **server-side** from `alert_rules.tenant_id` (already present after
this phase's schema work), never taken from anything in the request
body. This is a third RBAC category, alongside human roles (Phase 4
task 3), not a variant of session/token handling — it authorizes "run
this one already-persisted, already-tenant-scoped rule," never general
tenant access, so a compromised evaluator can't be used to browse
arbitrary tenant data.
## `TenantID`: an honest framing, not an oversold one
```go
package tenant
type contextKey struct{} // unexported key type -- closes a
// context.WithValue collision gap: an
// exported or string-typed key could be
// shadowed/overwritten by unrelated code
type ID struct{ value string } // unexported field
func (id ID) String() string { return id.value }
func FromContext(ctx context.Context) (ID, bool)
func WithContext(ctx context.Context, id ID) context.Context
// TrustFromValidatedSession is the only production construction path
// from a raw string. DO NOT CALL OUTSIDE auth middleware -- enforced by
// CI grep (hack/check-tenant-boundary.sh), same mechanism as the
// enterprise/-import-boundary check Phase 4 task 3 adds.
func TrustFromValidatedSession(raw string) ID
```
An unexported field with exactly one production constructor makes
*accidental* misuse cheap to audit — grep for call sites — it does not
make misuse impossible by the Go compiler alone, and this document
states that plainly rather than implying otherwise. Concretely:
- It does not stop a *deliberate* second construction path added later
inside the `tenant` package itself — e.g. a future `UnmarshalJSON`
method, added for some unrelated serialization need, which has full
access to the unexported field from within the package and would
happily decode a client-supplied JSON body straight into a trusted
`ID` the moment any handler unmarshals into a struct embedding one.
- **Correction made during implementation**: this design originally
proposed a test-only constructor in `internal/tenant/testing_test.go`,
reasoning that Go's exclusion of `_test.go` files from normal imports
would make it a compiler-enforced constructor reachable by other
packages' tests but not production code. That reasoning was wrong —
Go never compiles `_test.go` files into what *any* other package
imports, including other packages' own tests, so that constructor
would have been unreachable even from its intended callers. There is
no separate test constructor: other packages' tests call
`TrustFromValidatedSession` directly, which is fine, since test code
isn't attacker-controlled the way a network-facing handler is.
- The actual invariant, stated for what it is: *the constructor has
exactly one call site in non-test production code, verified by CI
grep (scanning `*.go`, excluding `*_test.go`) plus code review at
every change to `internal/tenant/`. The database/index grant layer
above is the real backstop. This package makes production violations
visible and rare — it does not make them impossible, and was never
able to restrict test-time construction either, only make it
unnecessary to restrict.*
## Provisioning state machine (summary — full detail in task 6's deploy work)
```
provisioning → active → suspended → deprovisioning → (removed)
```
- `provisioning`: `tenants` row exists, ClickHouse user/grants and
Tantivy index directory are being created. No request is ever served
for a tenant in this state.
- `active`: fully provisioned, narrow grants confirmed applied. Normal
serving state.
- `suspended`: grants revoked (e.g. non-payment, policy violation) but
data retained; no requests served, distinct from `deprovisioning` so
a suspension can be reversed without re-provisioning from scratch.
- `deprovisioning`: offboarding in progress — sessions/connections being
terminated, data export/deletion per retention policy (out of scope
for this document; a compliance/data-retention design, not an
isolation one).
## What this document deliberately does not solve here
- The RBAC role model and its enforcement (Phase 4 task 3, separate
sign-off).
- Audit logging mechanics (Phase 4 task 4).
- Exact SSO protocol flows (Phase 4 task 3).
- Deployment topology for pinning large tenants to dedicated cluster
nodes (Phase 4 task 6) — this document's model is agnostic to where a
tenant's database physically runs, only that it's a distinct
database/user regardless of placement.
- Data retention/deletion semantics during deprovisioning.
## Verification plan for this design specifically
Not just unit tests — this design's real risk is in ClickHouse's actual
runtime grant behavior on the pinned version in `docker-compose.yml`,
which is exactly the kind of thing that looks fine in documentation and
isn't in practice (see item 2 above). Phase 4 task 8's adversarial
suite must include, against the live stack:
- A tenant-scoped ClickHouse user attempting to read another tenant's
database by fully-qualified name in raw SQL.
- The same user attempting `system.query_log`, `system.tables`, `SHOW
DATABASES`.
- A Tantivy search request for one tenant returning zero cross-tenant
results even when another tenant's index contains matching terms.
- A simulated evaluator tick firing mid-provisioning (tenant row exists,
grants not yet confirmed) to confirm it's refused, not silently served
against a partially-provisioned or default-profile connection.
+213
View File
@@ -0,0 +1,213 @@
# RBAC design
> **Status:** Design, awaiting sign-off. Task 3 of Phase 4 — stop here
> before implementing enforcement/store code (`internal/rbacstore`,
> the `internal/session` middleware, RBAC checks wired into `/api`,
> `/alerting`, `/web`), per explicit instruction, same discipline as
> `/docs/phase-4-isolation-design.md`. Read that document first — this
> one assumes its module-placement decision (isolation and RBAC
> mechanisms live in `enterprise/` only) and its `alerting`↔`api`
> service-identity finding, which this doc's role model has to account
> for as a distinct category, not a variant of human roles.
## Why this design, in one paragraph
A plain three-tier viewer/editor/admin model (the original ask) has two
real gaps once you actually walk through who does what: nothing prevents
admins from locking each other out of a tenant entirely (the last admin
demotes themselves, or two admins race to remove each other — a shape of
bug organizations with real access-control systems hit often enough that
GitHub/GitLab-style products all converge on the same fix), and nothing
answers "who can grant additional access to a specific resource" — if
any editor can, an editor can silently self-escalate. This design adds a
non-removable `Owner` above `Admin` for the first gap, and scopes
per-resource grant management to resource creators + admins/owner for
the second, with every grant change itself audit-logged (task 4) — the
exact thing a security reviewer asks "show me every time access
widened" about. It also formalizes `alerting`'s evaluator as a distinct
**service identity** category, not a point on the human role scale,
because task 2's design doc found it needs categorically narrower
authority ("run this one already-persisted rule's query," never general
tenant browsing) than even a Viewer has.
## Roles
**Owner****Admin****Editor****Viewer**, strictly ordered — each
role's baseline access is a superset of the one below it. Exactly one
Owner per tenant at a time; Owner is non-removable and non-demotable
except by itself (voluntary transfer) or a platform operator
(break-glass, itself audit-logged and out of normal tenant-admin
control). This is the answer to "can admins lock each other out": they
can't lock out the Owner, and the Owner can always recover.
Plus **additive-only per-resource grants** — a `dashboard_permissions`
row lets a specific user exceed their baseline tenant role on one
specific dashboard (e.g. a Viewer given Editor-level access to one
dashboard they need to maintain, without making them a tenant-wide
Editor). No deny-overrides: a grant can never take away access someone's
baseline role already has. Deny-overrides (restricting a specific
Editor from a specific sensitive dashboard, say) are named future work,
not solved here — the additive-only model is simpler to reason about
and implement correctly, and covers the more common real need ("let this
one person help with this one thing").
Plus a distinct **service identity** category, not on the role scale at
all: `alerting`'s evaluator authenticates as itself (a service
credential, task 5), authorized narrowly to "execute the query belonging
to already-persisted rule X, where X's tenant is resolved server-side" —
never general read access to a tenant's dashboards, users, or anything
else. A compromised or buggy evaluator can re-run known rule queries; it
cannot browse.
## Permission matrix
| Action | Viewer | Editor | Admin | Owner |
|---|---|---|---|---|
| View dashboard (baseline or granted access) | ✓ | ✓ | ✓ | ✓ |
| Create dashboard | ✗ | ✓ | ✓ | ✓ |
| Edit/delete dashboard | ✗ | ✓ (own, or granted) | ✓ (any) | ✓ |
| Manage a dashboard's per-user grants | ✗ | ✓ (only if creator) | ✓ | ✓ |
| Run ad hoc query (UI/CLI/API) | ✓ | ✓ | ✓ | ✓ |
| Create/edit/delete alert rule | ✗ | ✓ | ✓ | ✓ |
| Enable/disable alert rule | ✗ | ✓ | ✓ | ✓ |
| View alert delivery log | ✓ | ✓ | ✓ | ✓ |
| Create/edit notification target | ✗ | ✓ | ✓ | ✓ |
| View notification target secret (webhook URL/token) | ✗ | ✗ | ✓ | ✓ |
| Delete notification target | ✗ | ✗ | ✓ | ✓ |
| View data source config | ✓ | ✓ | ✓ | ✓ |
| Manage tenant users / role assignments | ✗ | ✗ | ✓ (not Owner) | ✓ |
| Manage SSO config | ✗ | ✗ | ✓ | ✓ |
| View tenant audit log | own history only | own history only | ✓ | ✓ |
| Transfer tenant Owner | ✗ | ✗ | ✗ | ✓ (or platform break-glass) |
Every row in the "Admin/Owner-only" section that changes *someone else's*
access (role assignment, grant management, SSO config, Owner transfer) is
written to the audit log (task 4) as its own event type, distinct from a
query execution — reviewers ask for this list specifically.
Editors intentionally cannot see notification target secrets (webhook
URLs/tokens often encode credentials) even though they can select and
use a target when creating a rule — this mirrors the existing plaintext-
secret disclosure in `/docs/phase-3-alerting-design.md`'s "Known gaps":
Phase 3 already flagged `notification_targets.secret` as stored
plaintext; RBAC narrows *who can read it back*, it doesn't change how
it's stored (that's still named future hardening work, not solved here).
## `data_sources`: the honest scope of "per-data-source scoping"
Today, and through the end of this phase, every tenant has exactly one
data source: their own ClickHouse database + Tantivy index pair from
`/docs/phase-4-isolation-design.md`. A `data_sources` table (tenant-
scoped, one row auto-created per tenant at provisioning time) exists as
the extension point for a real future multi-source-per-tenant feature —
e.g. a tenant connecting a second ClickHouse cluster, or a distinct log
stream with its own retention. Role grants can reference a
`data_source_id` in the schema now, but with one data source per tenant
there is nothing meaningfully different a per-data-source grant does
yet. Stated plainly so this doesn't read as more built than it is.
## Schema
Lives in `/metadata` (`sentry_metadata`), alongside everything else from
Phase 3, per `/docs/phase-4-isolation-design.md`'s existing schema
additions (`tenants`, the `tenant_id` backfill on `alert_state`/
`delivery_log`). New tables, continuing that migration sequence:
```sql
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
-- Identity provenance, not a password -- SSO is the only login path.
-- One user row can in principle federate from either an OIDC or a
-- SAML IdP; which one isn't fixed at the user level, it's determined
-- per tenant_memberships row via the tenant's configured SSO method.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id TEXT NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
-- Application-level invariant (not a DB constraint -- Postgres has no
-- native "exactly one row matching a predicate per group" check):
-- exactly one 'owner' row per tenant_id at a time. Enforced in
-- internal/rbacstore's transfer/provisioning logic, not the schema.
CREATE TABLE data_sources (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL DEFAULT 'default',
clickhouse_database_name TEXT NOT NULL,
tantivy_index_path TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE dashboard_permissions (
id UUID PRIMARY KEY,
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
-- Additive only: this grant can only raise access above the user's
-- tenant-wide baseline role for this one dashboard, never lower it.
permission TEXT NOT NULL CHECK (permission IN ('viewer', 'editor')),
granted_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (dashboard_id, user_id)
);
```
`alert_rules`/`notification_targets`/`dashboards` already carry
`tenant_id` (Phase 3); no per-row ownership beyond `created_by` (already
present) is needed for the matrix above — "own vs. any" in the matrix is
`created_by = current user` vs. tenant-wide Admin/Owner authority, not a
separate grants table for those resource types.
## Enforcement shape (design only — implementation is task 5)
RBAC checks happen server-side, on every `/api`/`/alerting` endpoint
that touches tenant data — never UI-level button-hiding alone, per the
explicit requirement. The shape: middleware resolves `(tenant.ID, user,
role)` from a validated session (or, for `alerting`, the service
identity) before a handler runs; each handler declares the minimum role
an action requires; a request failing that check gets a 403 before any
tenant-scoped connection is even acquired — RBAC is a gate in front of
the isolation mechanism from `/docs/phase-4-isolation-design.md`, not a
replacement for it. Full middleware/handler wiring is task 5's scope.
## Web UI boundary: a runtime capability check, not a conditional import
Core `web` never bundles enterprise-licensed Svelte components into its
build — that would put commercial-licensed source inside an AGPL
artifact, the UI-layer equivalent of the Go import-boundary problem
`hack/check-tenant-boundary.sh` already guards against. Instead: core
`web` ships a generic settings/admin route
(`web/src/routes/settings/+page.svelte`, added in task 5) that, on load,
calls `GET {enterprise-auth base URL}/auth/features` and renders
sections conditionally based on the response:
```json
{"sso_configured": true, "oidc_enabled": true, "saml_enabled": false}
```
If `enterprise-auth` isn't deployed or configured, that fetch fails or
returns all-`false`, and the settings page simply shows core-only
content — no broken links, no "upgrade to unlock" dead ends, just an
absent section. This is a runtime capability check against a documented
REST contract, the same pattern `web` already uses for its two backend
base URLs (`apiBase`/`alertingBase` in `web/src/lib/api.ts`), not a new
mechanism — just pointed at a third, optional backend.
## What this document deliberately does not solve here
- Deny-override grants (named future work above).
- Enforcement middleware implementation (task 5).
- Audit logging of grant/role changes (task 4 builds the audit log
itself; this doc only names which actions must be logged).
- SSO-to-role mapping policy (e.g. IdP group claims auto-assigning
roles) — a real feature, not designed here; Phase 4's baseline is
manual role assignment by an Admin/Owner after a user's first SSO
login creates their `users` row.
+233
View File
@@ -0,0 +1,233 @@
# Phase 4 runbook
Extends `/docs/phase-0-runbook.md` through `/docs/phase-3-runbook.md`
with SSO plumbing, RBAC enforcement, tenant-scoped dashboards, audit
logging, and a Kubernetes deployment path. Read those first.
## Verification status — read this before the rest of this doc
Every prior phase's runbook documents claims **checked against the live
stack**, not asserted. This one is different, and says so plainly rather
than papering over it: **this session had no working Docker daemon
access and no reachable Kubernetes cluster**, so most of what follows is
a *procedure to run*, not a report of what was already run and passed.
Two exceptions, genuinely verified live against a real Postgres during
earlier Phase 4 tasks (see their own doc comments for the exact `docker
run` invocations):
- `enterprise/internal/audit`'s hash-chain, tamper-detection, and
concurrent-write guarantees (task 4).
- `enterprise/internal/rbacstore`'s CRUD, run against a live Postgres
the same way.
Everything else below — the auth-enforcement walkthrough, the dashboards
tenant-scoping fix, the Helm chart, the tenant-operator — has unit/fake-
client/`helm template` coverage (all passing, see each component's own
`go test`/`helm lint` output) but has **not** been exercised against a
real running stack in this session. If you're reading this to decide
whether Phase 4 is production-ready: it isn't yet, independent of this
gap — see `/docs/security/threat-model.md`'s headline finding (log-data
query isolation isn't built). This runbook exists so the first person
with real Docker/K8s access can actually close the loop, not to claim
that already happened.
## 1. Bring up the stack
```sh
docker compose build enterprise-auth api alerting web
docker compose up -d
docker compose ps
```
New service beyond Phase 3: `enterprise-auth` (port 8082) — see
`enterprise/README.md`. Not wired into `api`/`alerting`'s enforcement by
default (`docker-compose.yml`'s comment on why: no OIDC/SAML login flow
exists yet, so turning on enforcement by default would break the web UI
and `sentryctl` with no way to log in).
## 2. Confirm Phase 0-3 behavior is unchanged
Every existing single-tenant flow must still work exactly as before —
this is the regression check for the nil-authorizer no-op design running
through every piece of Phase 4 auth wiring:
```sh
curl -s -X POST http://localhost:8080/query -H 'Content-Type: application/json' -d '{"query":"stats count"}'
sentryctl dashboards list
curl -s http://localhost:8081/healthz
```
All three should behave exactly as in the Phase 3 runbook — no auth
required, since `ENTERPRISE_AUTH_URL`/`API_SERVICE_TOKEN` aren't set.
## 3. `enterprise-auth`: mint and validate a service token
```sh
curl -s http://localhost:8082/healthz && echo " <- OK"
TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=alerting)
echo "$TOKEN"
curl -s -X POST http://localhost:8082/internal/authorize -H "Authorization: Bearer $TOKEN"
# expect: {"tenant_id":"","user_id":"","role":"service"}
curl -s -o /dev/null -w "invalid token -> %{http_code}\n" \
-X POST http://localhost:8082/internal/authorize -H "Authorization: Bearer garbage"
# expect: 401
curl -s http://localhost:8082/auth/features
# expect: {"sso_configured":false,"oidc_enabled":false,"saml_enabled":false}
# (no OIDC_ISSUER_URL/SAML_IDP_METADATA_URL set in this compose file)
```
## 4. Turn on RBAC enforcement and prove it actually blocks/allows
Without touching the main stack's `api` container (so step 2's baseline
keeps working):
```sh
docker compose run --rm -d --name sentry-api-enforced -p 8090:8080 \
-e ENTERPRISE_AUTH_URL=http://enterprise-auth:8082 api
curl -s -o /dev/null -w "no auth -> %{http_code} (want 401)\n" \
-X POST http://localhost:8090/query -H 'Content-Type: application/json' -d '{"query":"stats count"}'
curl -s -o /dev/null -w "with service token -> %{http_code} (want 200)\n" \
-X POST http://localhost:8090/query -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"query":"stats count"}'
docker stop sentry-api-enforced
```
`GET /dashboards` on the same enforced instance should return 401
without a token — there's no way to mint a human (Viewer/Editor/etc.)
session yet (no OIDC/SAML login handler exists — see
`enterprise/cmd/enterprise-auth/main.go`'s doc comment), so this
runbook can't walk through a real human RBAC scenario end to end. That
gap is real, not an oversight in this runbook.
## 5. Dashboards tenant scoping
This is the fix from Phase 4 task 7/8 (see `/docs/security/threat-model.md`)
— every dashboards query is now scoped to the authenticated identity's
tenant. Verify the real SQL, not just the fake-store unit tests:
```sh
docker run --rm --network sentry_default -v $(pwd)/api:/src -w /src \
-e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
-e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \
golang:1.25-alpine go test ./internal/dashboards/... -run Integration -v
```
Expect all `TestIntegration*` tests to pass, including
`TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant` (the
`tenant_id` foreign key added in
`metadata/migrations/0027_add_dashboards_tenant_fk.sql` rejecting a
dashboard for a tenant that doesn't exist).
## 6. `enterprise/internal/rbacstore` and `internal/audit` (already verified — reconfirm here)
```sh
docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
-e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
-e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \
golang:1.25-alpine go test ./internal/rbacstore/... -v
docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
-e AUDIT_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
-e AUDIT_TEST_POSTGRES_PASSWORD=audit-writer-dev-only \
-e AUDIT_TEST_ADMIN_PASSWORD=sentry-dev-only \
golang:1.25-alpine go test ./internal/audit/... -v
```
## 7. `deploy`: Helm chart and Operator (offline-only so far — see `/deploy/README.md`)
No live cluster was available to `kubectl apply` any of this. What can
be checked without one:
```sh
cd deploy/operator && go build ./... && go vet ./... && go test ./...
cd ../helm/sentry
helm lint .
helm template sentry . --include-crds > /tmp/default.yaml
helm template sentry . --include-crds \
--set enterprise.enabled=true --set tenantOperator.enabled=true \
--set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' \
--set 'tenants[1].name=globex' --set 'tenants[1].displayName=Globex Corporation' \
> /tmp/multitenant.yaml
```
With a real cluster reachable (`kind create cluster`, or similar):
```sh
docker build -f deploy/operator/Dockerfile -t sentry-tenant-operator deploy/operator/
kind load docker-image sentry-tenant-operator # or push to a registry the cluster can pull from
helm install sentry deploy/helm/sentry --include-crds \
--set tenantOperator.enabled=true --set enterprise.enabled=true \
--set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp'
kubectl get tenants
kubectl get secret sentry-tenant-acme-clickhouse -o yaml
```
Expect `kubectl get tenants` to show `acme` reach `status.phase: Active`
and the Secret to contain a generated `username`/`password`/`database`.
This proves the K8s-side half of a real two-tenant deployment — it does
**not** prove either tenant has a working ClickHouse database, since
`enterprise/internal/tenantprovision` (the piece that would create one)
isn't built. See `/deploy/README.md` and
`/docs/security/threat-model.md`.
## Known gaps (do not treat this phase as done without reading these)
Full accounting: `/docs/security/threat-model.md`. Headline items:
- **No tenant isolation on log data.** `POST /query` executes against
one shared ClickHouse connection and one shared Tantivy index for
every tenant, regardless of RBAC. This is Phase 4's originally-stated
highest-risk item and it is not resolved.
- **No human SSO login.** OIDC/SAML protocol wiring exists;
the HTTP login/callback handlers that would use it don't.
- **No per-resource dashboard grants** (`dashboard_permissions` has a
schema, no handler reads it).
- Four adversarial ClickHouse/Tantivy probes named in
`/docs/phase-4-isolation-design.md`'s verification plan are stubbed as
explicitly-skipped tests in `api/internal/queryapi/
tenant_isolation_gap_test.go`, blocked on the tenant-scoped connection
work above.
## Tearing down
```sh
docker compose down -v
helm uninstall sentry # if installed against a real cluster
```
## Troubleshooting
**`enterprise-auth` fails to start with "ENTERPRISE_SESSION_SIGNING_KEY
must be set to at least 32 bytes".**
Required, unlike OIDC/SAML config — see `enterprise/internal/config.Load`.
`docker-compose.yml`'s dev value is long enough; a custom override must
be too.
**`POST /query` returns 401 even though `ENTERPRISE_AUTH_URL` isn't
set.**
Check `api/cmd/api/main.go` actually left `authorizer` nil when
`cfg.EnterpriseAuthURL == ""` — a nil `Authorizer` must be a no-op
(`api/internal/authz.RequireRole`'s doc comment). If this regresses, it
breaks every existing Phase 0-3 deployment silently.
**A dashboard created by one tenant is visible to another.**
This is the exact bug found and fixed in task 7 — see
`/docs/security/threat-model.md`'s "application-layer tenant scoping"
section and `api/internal/dashboards/handler_test.go`'s
`TestCrossTenant*` tests. If this regresses, `Handler.tenantID` or
`store.go`'s `WHERE tenant_id = ...` filters have been bypassed
somewhere — check every store method still takes and uses a `tenantID`
parameter.
**`helm template` fails with `error calling include: ... can't evaluate
field Release in type string`.**
A call site is passing a bare string to `sentry.selectorLabels` instead
of `(list $ "name")` — see `templates/_helpers.tpl`'s doc comment for
why the plain-string form doesn't work with `include`.
+289
View File
@@ -0,0 +1,289 @@
# Sentry Threat Model (Phase 4)
Written for a prospective enterprise customer's security team, describing
the system **as actually built** through Phase 4 task 7 — not the target
architecture. Where a control is designed but not yet implemented, this
document says so explicitly, with a pointer to the tracking doc/task.
See `/docs/phase-4-isolation-design.md` and `/docs/phase-4-rbac-design.md`
for the full design rationale behind the controls described here.
## Read this first: the single most important open finding
**Log data queried through `POST /query` is not tenant-isolated today.**
Every authenticated tenant's ad hoc queries and dashboard panel queries
execute against the same shared ClickHouse connection and the same
shared Tantivy index — there is no per-tenant database, user, or index
routing anywhere in the query execution path
(`api/internal/querylang/executor.SQLRunner`/`SearchClient`, `search`'s
gRPC service, `proto/sentry/search/v1/search.proto`). Confirmed by
reading the actual code, not assumed: neither interface, nor the
`search` proto, carries a tenant field anywhere.
This is exactly the mechanism `/docs/phase-4-isolation-design.md`
specifies as the core deliverable of tenant isolation (one dedicated
ClickHouse database/user and one dedicated Tantivy index directory per
tenant) — it is **designed but not built**. What *is* built and live:
role-based access control (below) and tenant-scoped control-plane data
(dashboards, below). Until `enterprise/internal/chrunner` and
`enterprise/internal/searchclient` exist and are wired into
`api/internal/queryapi.Handler` in place of the single shared connection
`api/cmd/api/main.go` opens today, **treat any deployment of this system
as single-tenant only**, regardless of how many `Tenant` CRs or
`tenant_memberships` rows exist. RBAC controls who can run a query; they
do not control what data that query can see.
## System overview
```
Browser ──▶ web (SvelteKit, static)
Browser ──▶ api ──▶ ClickHouse (log data, SQL path)
│ └─▶ search (gRPC) ──▶ Tantivy (log data, full-text path)
└─▶ Postgres (control plane: dashboards, alert_rules,
tenants, users, tenant_memberships, audit_log)
alerting ──▶ api (POST /query, RoleService credential)
alerting ──▶ Postgres (rulestore, notifystore)
api/alerting ──▶ enterprise-auth (POST /internal/authorize, HTTP only —
no Go import edge, see "Module
boundary" below)
sentryctl ──▶ api, alerting (Bearer token when SENTRYCTL_TOKEN is set)
```
Ingest path (agent → Redpanda → ingest → ClickHouse, and Redpanda →
search → Tantivy) carries no tenant concept at all yet either — every
ingested log record lands in the one shared `logs` table/index. Tenant
isolation for *ingest*, not just query, is out of scope for what's built
so far and is not separately designed in
`/docs/phase-4-isolation-design.md`; named here as a gap that design doc
doesn't yet cover, not just an implementation gap.
## Module boundary (trust boundary #1)
`enterprise/` (commercial license: SSO, RBAC storage, audit logging,
session issuance) is never imported by AGPL core (`/api`, `/alerting`,
`/web`, `/cli`) — enforced in CI by `hack/check-tenant-boundary.sh`,
which greps for the import edge on every build. Core calls
`enterprise-auth` over plain HTTP (`api/internal/authz.HTTPAuthorizer`),
forwarding only the `Cookie`/`Authorization` headers, never the full
request (`api/internal/authz/httpauthz_test.go` asserts this — an
unrelated header like `X-Forwarded-For` is never forwarded). This means
core's authorization decision is only as trustworthy as the network path
to `enterprise-auth` — see "Deployment/network assumptions" below.
## Authentication
**Not implemented for human users.** `enterprise/internal/oidc` and
`enterprise/internal/saml` wire `coreos/go-oidc`/`crewjam/saml` for the
protocol mechanics (discovery, AuthnRequest generation, token/assertion
validation), but no HTTP handler calls them — there is no
`/auth/oidc/login`, `/auth/oidc/callback`, or SAML ACS endpoint. A human
cannot log in today. `GET /auth/features` (`enterprise/internal/
authhandler`) reports whether OIDC/SAML are *configured* (for `/web`'s
settings page to conditionally render), which is independent of whether
login actually works.
**Implemented for the one machine caller.** `/alerting`'s evaluator is
the sole service-to-service caller (`POST /query`, to evaluate rule
conditions across tenants). It presents a long-lived, signed
(HS256/JWT) `RoleService` credential, minted offline via
`enterprise-auth -mint-service-token=alerting` (an operator action, not
a network-reachable endpoint) and configured via `API_SERVICE_TOKEN`.
`enterprise/internal/session.Manager` issues and validates this token;
`enterprise/internal/authhandler`'s `POST /internal/authorize` resolves
it. `RoleService` is a distinct, non-comparable lane on the `Role` type
(`api/internal/authz.Role.Satisfies`) — a service credential can never
satisfy a human-role check and vice versa, verified by exhaustive
table-driven tests (`api/internal/authz/authz_test.go`).
**Session/token integrity.** Tokens are HS256-signed JWTs with a single
shared signing key (`ENTERPRISE_SESSION_SIGNING_KEY`, ≥32 bytes,
required at `enterprise-auth` startup). Compromise of this key lets an
attacker forge any identity, including `RoleService` — it is the single
highest-value secret in the enterprise deployment and should be treated
accordingly (a real KMS/secrets-manager-backed value, not the
`docker-compose.yml`/Helm chart's dev-only literal). Token validation
(`enterprise/internal/session.Manager.Validate`) collapses every failure
mode — bad signature, malformed token, expired — into one
`ErrInvalidToken`, deliberately not distinguishing "expired" from
"forged" so a caller can't be tempted to treat either as a softer case.
## Authorization (RBAC)
**Live and enforced.** `POST /query` and every `/dashboards` endpoint in
`api` require a minimum role, resolved per-request via
`api/internal/authz.RequireRole`/`RequireRoleOrService` calling
`enterprise-auth`. Roles: Viewer < Editor < Admin < Owner, plus the
separate `RoleService` lane above. `GET /dashboards` is Viewer+;
create/update/delete require Editor+ (`api/internal/dashboards/
handler.go`). A nil `Authorizer` (no `ENTERPRISE_AUTH_URL` configured)
is a deliberate no-op, matching Phase 0-3's no-auth behavior — this is
correct default-open-for-single-tenant behavior, not an oversight, but
means an operator who forgets to set `ENTERPRISE_AUTH_URL` in a
multi-tenant deployment gets *no* enforcement at all, silently. Worth a
deployment-time check a real rollout should add (not built here).
**Not yet enforced:** the RBAC matrix's `(own/granted)` qualifier for
Editor-level dashboard actions — `dashboard_permissions` (per-resource
grants beyond a user's tenant-baseline role) has a schema
(`metadata/migrations/0024_create_dashboard_permissions.sql`) but no
handler reads it yet. Every Editor in a tenant can act on every
dashboard in that tenant, not just their own/granted ones.
**Application-layer tenant scoping (dashboards only).** Every
`dashboards` store query filters `WHERE tenant_id = $identity.TenantID`
(`api/internal/dashboards/store.go`), and the handler resolves that
tenant ID from the RBAC-authenticated identity's context
(`authz.IdentityFromContext`), **never** from a client-supplied request
field. This closes a real gap found during this document's own review:
`Dashboard.TenantID` is a JSON-tagged, client-settable field
(`api/internal/dashboards/types.go`), and the original handler/store
implementation trusted it directly on create/update and applied no
`tenant_id` filter at all on list/get/update/delete — meaning any
authenticated user could read, modify, or delete any other tenant's
dashboards simply by supplying (or guessing) their UUID, or spoof
`tenant_id` on create/import to write into a tenant they don't belong
to. Fixed as part of this task, with regression tests proving
cross-tenant access now returns 404 (not 403, which would itself leak
that the ID exists under a different tenant) —
`api/internal/dashboards/handler_test.go`'s
`TestCrossTenant*`/`TestCreateDashboardIgnoresClientSuppliedTenantID`/
`TestImportIgnoresExportedTenantID`. **This same class of bug should be
assumed present anywhere else client-supplied identifiers cross a tenant
boundary until proven otherwise by an adversarial test** — see task 8's
adversarial test suite for what's been checked so far and what hasn't.
**Query-path tenant scoping: none** — see the top of this document.
RBAC's role check on `POST /query` answers "is this identity allowed to
run *a* query," not "does this query's result set respect tenant
boundaries" — it can't, because the executor has no tenant concept to
enforce.
## Audit logging
**Live**, and independently verified against a real Postgres (not just
written) — `enterprise/internal/audit`'s integration tests. Two
independent defenses back "no update/delete path from the application
layer":
1. A dedicated `audit_writer` Postgres role with only `INSERT`+`SELECT`
grants (`metadata/migrations/0012-0014`), via its **own**
`pgxpool.Pool` — never the shared `sentry` role/pool every other
store uses.
2. A `BEFORE UPDATE OR DELETE ... RAISE EXCEPTION` trigger
(`metadata/migrations/0015-0016`) that rejects the operation for
*any* role, including the table owner — confirmed live: even the
`sentry` role cannot `UPDATE` a row without first disabling the
trigger, a privileged operation distinct from ordinary application
access.
**Tamper detection, not tamper prevention against a privileged
attacker.** Rows are hash-chained (`prev_hash`/`row_hash =
SHA256(prev_hash || canonical_fields)`, serialized under
`pg_advisory_xact_lock` so concurrent writers can't fork the chain —
verified with a 20-goroutine concurrency test against live Postgres).
The chain alone only proves internal self-consistency: a Postgres
superuser (or anyone who compromises that credential) can wipe
`audit_log` and regenerate a perfectly self-consistent new chain from
row 1. `enterprise/internal/audit.Checkpointer` periodically ships a
rolling hash to an external `CheckpointSink` for exactly this reason —
`FileSink` (the only implementation built so far) is explicitly
documented as a dev/testing stand-in, **not** a real external-anchoring
guarantee (it writes to a local file the same privileged attacker could
also reach). A real deployment needs a genuine `CheckpointSink`
(S3 with Object Lock, or equivalent, reachable by a credential the
database administrator doesn't also hold) before the "prove nothing was
altered after the fact" claim actually holds against a privileged
insider.
**Fail-open by design for routine queries.** `queryapi.Handler.logAudit`
(`api/internal/queryapi/handler.go`) logs a write failure and otherwise
ignores it — an audit-log outage does not take down the query path. This
is a deliberate availability-over-completeness tradeoff: it means a
brief audit outage produces an under-logged (not over-blocked) window.
No privileged/administrative action (role change, SSO config change,
notification-target secret reveal) currently exists to enforce
fail-closed on, since none of those flows are built yet
(`enterprise/internal/rbacstore` has no HTTP handlers) — when they are,
they should fail closed per `/docs/phase-4-isolation-design.md`'s
original policy, and that policy is not yet exercised by any real code
path.
**What's logged:** query text, language, row count, duration,
success/error — not result contents. `Source`/`EventType` fields exist
(`SourceAPI`/`SourceWeb`/`SourceCLI`/`SourceAlerting`,
`EventQuery`/`EventRoleChange`/`EventGrantChange`/
`EventSSOConfigChange`/`EventSecretReveal`) but only `EventQuery` from
`SourceAPI` is actually wired to a call site
(`queryapi.Handler.logAudit`) — the others are typed placeholders for
work not yet built (there's no role-change/grant-change/SSO-config
handler to call them from).
## Known residual risks (explicitly out of scope, not silently assumed away)
Per `/CLAUDE.md`'s Phase 4 non-goals, restated here in threat-model
terms:
- **A privileged ClickHouse/Postgres administrator is not defended
against.** Every isolation and audit-integrity guarantee in this
document is a structural defense against *application-layer* bugs and
injection — not against someone holding database superuser
credentials. That's an operational control (credential custody,
infrastructure access review), out of scope for this system's own
code.
- **`system.query_log` metadata leakage** (task 2's finding): once
per-tenant ClickHouse users exist, `system.query_log` and related
`system.*` tables can expose other tenants' query *text* (predicate
values, field names) even if row-level isolation between databases
works perfectly. The design calls for revoking `system.*` access from
every tenant user explicitly, not relying on ClickHouse's default
template — this can only be verified once per-tenant users actually
exist (they don't yet; see the top of this document), so it remains
an open verification item, not a closed one.
- **No deny-override grants** — `dashboard_permissions` is additive-only
by design; a full allow/deny ACL system is unbuilt, future work.
- **No data retention/deletion policy** for a deprovisioned tenant —
the `tenants.status` state machine includes `deprovisioning`, but what
actually happens to that tenant's ClickHouse/Tantivy/Postgres data is
an unanswered compliance question, not a designed-and-deferred one.
- **No general multi-cluster orchestration** — `/deploy`'s Helm
chart/Operator (`/deploy/README.md`) proves the K8s-side per-tenant
secret-management model, not a fully general multi-cluster system, and
was never applied to a live cluster in this environment (see that
README's verification section).
## Deployment/network assumptions
- `enterprise-auth`'s `/internal/authorize` and `/auth/features`
endpoints have no authentication of their own beyond the credentials
they're validating — they must be reachable only from inside the
cluster/trusted network (`api`/`alerting`/`web`), never exposed
publicly. Nothing in this codebase enforces that at the network layer;
it's a deployment responsibility (NetworkPolicy, or equivalent) not
yet codified in `/deploy/helm/sentry`.
- `ENTERPRISE_SESSION_SIGNING_KEY`, ClickHouse/Postgres passwords, and
(once minted) the `alerting` service token are all K8s `Secret`
objects in the Helm chart (`/deploy/helm/sentry/templates/
secrets.yaml`) — standard K8s `Secret` semantics apply (base64, not
encrypted at rest without a cluster-level `EncryptionConfiguration`).
No secrets-manager integration (Vault, cloud KMS) exists; the chart
documents this as an operator decision, not something it enforces.
## Summary: what's actually enforced today
| Control | Status |
|---|---|
| Role-based access control on `/query`, `/dashboards` | **Enforced** |
| `alerting``api` service-identity credential | **Enforced** |
| Tenant scoping on dashboards (control-plane data) | **Enforced** (fixed this task) |
| Tenant isolation on log data (`/query` → ClickHouse/Tantivy) | **Not implemented** |
| Human SSO login (OIDC/SAML) | **Not implemented** |
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
| Query audit logging (routine queries) | **Enforced**, fail-open |
| Audit log tamper detection (hash chain) | **Enforced**, verified live |
| Audit log tamper prevention (external anchoring) | **Design only**`FileSink` is a dev stand-in |
| `system.*` ClickHouse metadata isolation | **Unverified** — depends on unbuilt per-tenant users |
| Protection against a privileged DB administrator | **Explicit non-goal** |