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:
@@ -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
@@ -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
|
||||
+1
-1
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
+20
-28
@@ -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 {
|
||||
Reference in New Issue
Block a user