Phase 4: real per-tenant ClickHouse isolation via a new enterprise-api binary

Closes the threat model's headline finding for the SQL query path:
enterprise/internal/tenantprovision does real CREATE DATABASE/USER/GRANT
against ClickHouse, and enterprise/internal/chrunner is a per-tenant
connection registry implementing api's SQLRunner interface, resolving
the tenant from the authenticated request identity -- never a
caller-suppliable parameter. Both are wired into a new binary,
enterprise/cmd/enterprise-api, alongside the unchanged single-tenant
api/cmd/api, since AGPL core can never import enterprise/ and Go's own
internal/ package visibility rules meant enterprise/ couldn't implement
core's SQLRunner interface without importing the package that defines
it. That required moving api/internal/{authz,queryapi,dashboards,
querylang/executor,searchclient,httpserver} out of internal/ -- the
minimal set enterprise-api needs to import; querylang's compiler
internals (planner/lexer/parser/ast/ir) and api's own config stay
internal, since nothing outside api needs them directly.

Also finally wires enterprise/internal/audit into queryapi.AuditLogger
(nil since Phase 4 task 4) via a new adapter, and adds live-ClickHouse
integration tests for two of the four adversarial probes named in
docs/phase-4-isolation-design.md's verification plan.

Corrected several overclaims in the docs while writing this up: an
earlier claim that rbacstore's CRUD was "verified against a live
Postgres" was never actually true in this environment (only
internal/audit was, earlier in this phase, before Docker access was
lost) -- threat-model.md, phase-4-runbook.md, CLAUDE.md, and
enterprise/README.md all now distinguish "a real integration test
exists" from "this was confirmed against a live database."

Still not built: Tantivy/free-text tenant isolation
(enterprise/internal/searchclient), and any deployment-topology
mechanism that actually routes traffic to enterprise-api instead of
plain api -- both binaries exist side by side today with nothing
enforcing or flagging which one a deployment runs.
This commit is contained in:
2026-08-13 22:48:38 -07:00
parent 3eb0f4c589
commit 1d57e697b1
49 changed files with 2003 additions and 237 deletions
@@ -75,6 +75,13 @@ func IdentityFromContext(ctx context.Context) (Identity, bool) {
return id, ok
}
func withIdentity(ctx context.Context, id Identity) context.Context {
// WithIdentity attaches an already-resolved Identity to ctx -- exported
// (not just middleware.go's internal use) so packages that construct
// their own request context outside an HTTP handler -- e.g. enterprise/
// internal/chrunner's tests, or a future non-HTTP caller -- can put a
// real Identity in context the same way RequireRole/RequireRoleOrService
// do, rather than reaching for an unexported field via reflection or
// duplicating this one-line function.
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityContextKey{}, id)
}
@@ -42,7 +42,7 @@ func RequireRole(authorizer Authorizer, minRole Role, next http.HandlerFunc) htt
writeForbidden(w)
return
}
next(w, r.WithContext(withIdentity(r.Context(), identity)))
next(w, r.WithContext(WithIdentity(r.Context(), identity)))
}
}
@@ -67,6 +67,6 @@ func RequireRoleOrService(authorizer Authorizer, minRole Role, next http.Handler
writeForbidden(w)
return
}
next(w, r.WithContext(withIdentity(r.Context(), identity)))
next(w, r.WithContext(WithIdentity(r.Context(), identity)))
}
}
+8 -8
View File
@@ -1,7 +1,7 @@
// Command api is Sentry's query API: a single POST /query endpoint
// accepting either the pipe syntax or raw SQL, compiled and routed
// across ClickHouse and search by internal/querylang. See
// internal/queryapi and /docs/query-language-design.md for why this is
// queryapi and /docs/query-language-design.md for why this is
// plain REST rather than the pinned gRPC+gateway pattern.
package main
@@ -19,13 +19,13 @@ import (
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/internal/authz"
"github.com/sentry/sentry/api/authz"
"github.com/sentry/sentry/api/dashboards"
"github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/api/internal/config"
"github.com/sentry/sentry/api/internal/dashboards"
"github.com/sentry/sentry/api/internal/httpserver"
"github.com/sentry/sentry/api/internal/queryapi"
"github.com/sentry/sentry/api/internal/querylang/executor"
"github.com/sentry/sentry/api/internal/searchclient"
"github.com/sentry/sentry/api/queryapi"
"github.com/sentry/sentry/api/querylang/executor"
"github.com/sentry/sentry/api/searchclient"
)
func main() {
@@ -104,7 +104,7 @@ func main() {
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer)
// One shared mux, CORS applied once around the whole thing -- see
// internal/httpserver's doc comment for why this changed from each
// httpserver's doc comment for why this changed from each
// handler wrapping itself individually.
mux := http.NewServeMux()
queryHandler.RegisterRoutes(mux)
@@ -7,7 +7,7 @@ import (
"log/slog"
"net/http"
"github.com/sentry/sentry/api/internal/authz"
"github.com/sentry/sentry/api/authz"
)
// store is the narrow interface Handler depends on -- *Store (store.go)
@@ -11,7 +11,7 @@ import (
"strings"
"testing"
"github.com/sentry/sentry/api/internal/authz"
"github.com/sentry/sentry/api/authz"
)
// fakeStore enforces tenant scoping the same way store.go's real
@@ -431,7 +431,7 @@ func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
// TestServiceIdentityCannotAccessDashboards is the other half of the
// service-identity boundary (the /query half is
// api/internal/queryapi's own tests) -- api/internal/authz's own tests
// api/queryapi's own tests) -- api/authz's own tests
// already prove RequireRole rejects RoleService in isolation
// (TestRequireRolePlainDoesNotAllowService); this proves it holds
// through the real dashboards handler, wired the way it's actually
@@ -12,7 +12,7 @@
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -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
// golang:1.25-alpine go test ./dashboards/... -run Integration -v
package dashboards
import (
@@ -1,6 +1,6 @@
// Package httpserver holds cross-handler HTTP concerns for /api. Phase 3
// introduced a second handler package (internal/dashboards) alongside
// internal/queryapi, so CORS moved out of individual handlers into one
// introduced a second handler package (dashboards) alongside
// queryapi, so CORS moved out of individual handlers into one
// wrap applied around the fully-assembled mux in cmd/api/main.go, rather
// than each handler package wrapping itself.
package httpserver
+1 -1
View File
@@ -74,7 +74,7 @@ func (FreeText) isTerm() {}
type TimeExpr struct {
Absolute string
IsRelative bool
RelativeSign int // -1 or +1
RelativeSign int // -1 or +1
RelativeN int
RelativeUnit string // "s" | "m" | "h" | "d" | "w"
}
+17 -17
View File
@@ -12,23 +12,23 @@ type Kind int
const (
EOF Kind = iota
Illegal
Ident // bare words: field names, keywords, unquoted values/free-text terms
String // quoted string: "..."
Number // 123, 1.5
Pipe // |
Eq // =
Neq // !=
Gt // >
Gte // >=
Lt // <
Lte // <=
Colon // :
Comma // ,
LParen // (
RParen // )
Minus // -
Plus // +
Star // * (only meaningful inside count(*), same as SQL)
Ident // bare words: field names, keywords, unquoted values/free-text terms
String // quoted string: "..."
Number // 123, 1.5
Pipe // |
Eq // =
Neq // !=
Gt // >
Gte // >=
Lt // <
Lte // <=
Colon // :
Comma // ,
LParen // (
RParen // )
Minus // -
Plus // +
Star // * (only meaningful inside count(*), same as SQL)
)
type Token struct {
+1 -1
View File
@@ -18,7 +18,7 @@ import (
type Language string
const (
Auto Language = "" // detect from the query text (default)
Auto Language = "" // detect from the query text (default)
SQL Language = "sql"
SPL Language = "spl" // the pipe syntax; named to match the query-language-reference doc
)
@@ -19,9 +19,9 @@ import (
"strings"
"time"
"github.com/sentry/sentry/api/internal/authz"
"github.com/sentry/sentry/api/internal/querylang/executor"
"github.com/sentry/sentry/api/authz"
"github.com/sentry/sentry/api/internal/querylang/planner"
"github.com/sentry/sentry/api/querylang/executor"
)
// AuditLogger is core's extension point for query audit logging --
@@ -74,7 +74,7 @@ func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search execut
}
// RegisterRoutes adds this handler's routes onto a shared mux. Phase 3
// introduced a second handler package (internal/dashboards), so CORS is
// introduced a second handler package (dashboards), so CORS is
// now applied once, by main.go, around the fully-assembled mux rather
// than by each handler wrapping itself individually -- see
// httpserver.WithCORS.
@@ -12,8 +12,8 @@ import (
"testing"
"time"
"github.com/sentry/sentry/api/internal/authz"
"github.com/sentry/sentry/api/internal/querylang/executor"
"github.com/sentry/sentry/api/authz"
"github.com/sentry/sentry/api/querylang/executor"
)
type fakeSQLRunner struct {
@@ -1,38 +1,30 @@
// This file is a checklist, not a passing test suite -- it exists so
// the four adversarial probes /docs/phase-4-isolation-design.md's
// This file is a checklist, not a fully passing test suite -- it exists
// so the four adversarial probes /docs/phase-4-isolation-design.md's
// "Verification plan for this design specifically" section names for
// Phase 4 task 8 have a permanent, grep-able home in the test tree,
// even though none of them can run for real yet.
// Phase 4 task 8 have a permanent, grep-able home in the test tree.
//
// Why they can't run: every one of these probes needs a *per-tenant*
// ClickHouse user/database or Tantivy index to attack -- and none
// exist. api/internal/querylang/executor.SQLRunner/SearchClient (the
// only two interfaces api/internal/queryapi.Handler talks to) carry no
// tenant field at all, confirmed by reading both interfaces; neither
// does proto/sentry/search/v1/search.proto's SearchRequest. See
// /docs/security/threat-model.md's "Read this first" section for the
// full writeup -- there is currently exactly one shared ClickHouse
// connection and one shared Tantivy index for every tenant, so "does
// tenant A's connection leak tenant B's data" has no meaningful
// operational answer yet: there's only one connection.
// Item 1 (fully-qualified cross-tenant raw SQL) is no longer blocked:
// enterprise/internal/tenantprovision and enterprise/internal/chrunner
// now exist, and both have real, passing (when run against a live
// ClickHouse) tests for exactly this probe --
// enterprise/internal/tenantprovision/tenantprovision_test.go's
// TestProvisionedUserCannotReadOtherTenantDatabase (at the raw
// ClickHouse-user layer) and enterprise/internal/chrunner/
// chrunner_test.go's TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL
// (through the actual query-execution code path api/queryapi.Handler
// calls in production, when fronted by enterprise/cmd/enterprise-api
// instead of plain api/cmd/api). Nothing to assert here anymore for
// item 1 -- see those two tests instead.
//
// Each Skip below names precisely what has to exist before that test
// can be written for real (enterprise/internal/tenantprovision,
// enterprise/internal/chrunner, enterprise/internal/searchclient -- all
// still unbuilt, per the Phase 4 task 5 summary). Turning a Skip here
// into a real assertion is the acceptance criterion for those packages,
// not a nice-to-have follow-up.
// Items 2-4 remain blocked, for the reasons each Skip below states.
// Note the scope boundary this leaves: even with chrunner wired in,
// there is still exactly one shared Tantivy index for every tenant
// (enterprise/internal/searchclient, the Tantivy-side equivalent of
// chrunner, is unbuilt) -- see /docs/security/threat-model.md.
package queryapi
import "testing"
func TestAdversarial_ClickHouseUserCannotReadOtherTenantDatabaseByFullyQualifiedName(t *testing.T) {
t.Skip("BLOCKED on enterprise/internal/tenantprovision + enterprise/internal/chrunner: " +
"needs two real per-tenant ClickHouse users/databases to attempt " +
"`SELECT * FROM other_tenant_db.logs` against. See " +
"/docs/phase-4-isolation-design.md's verification plan, item 1.")
}
func TestAdversarial_ClickHouseUserCannotReadSystemTables(t *testing.T) {
t.Skip("BLOCKED on enterprise/internal/tenantprovision: needs a real per-tenant " +
"ClickHouse user to attempt `SELECT * FROM system.query_log`, " +
@@ -12,7 +12,7 @@ import (
// ClickHouse and shapes the result into JSON-friendly columns/rows,
// discovering the result's column set at query time via reflection since
// the query itself is arbitrary. Ported from Phase 0/1's
// api/internal/queryapi.Executor, which this replaces (see task 4) --
// api/queryapi.Executor, which this replaces (see task 4) --
// same logic, moved here since it's the query-execution layer's
// plumbing, not specific to the old placeholder /query handler.
type ChRunner struct {