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:
+89
-22
@@ -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
|
||||
|
||||
|
||||
@@ -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 0–3 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 0–3-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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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** |
|
||||
Reference in New Issue
Block a user