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:
@@ -140,18 +140,29 @@ Non-goals for this phase (same discipline as every phase so far):
|
|||||||
|
|
||||||
## What "done" looks like for Phase 4
|
## What "done" looks like for Phase 4
|
||||||
|
|
||||||
**Status: in progress, not shipped.** Through task 8: RBAC enforcement
|
**Status: in progress, not shipped.** RBAC enforcement (`api/authz`), the
|
||||||
(`api/internal/authz`), the `alerting`↔`api` service-identity credential,
|
`alerting`↔`api` service-identity credential, tenant-scoped dashboards,
|
||||||
tenant-scoped dashboards, and append-only audit logging are built and
|
append-only audit logging, and — since the second pass on this phase —
|
||||||
tested (including live-Postgres verification for audit logging and
|
real per-tenant ClickHouse provisioning and query routing
|
||||||
rbacstore). The two items this phase's exit criteria below actually
|
(`enterprise/internal/tenantprovision`, `enterprise/internal/chrunner`,
|
||||||
hinge on are **not** built: SSO login (OIDC/SAML protocol wiring exists;
|
wired into a new `enterprise/cmd/enterprise-api` binary alongside plain
|
||||||
no HTTP login handler calls it) and — the highest-risk one — tenant
|
`api/cmd/api`) are all built and tested — real integration tests exist
|
||||||
isolation for log data itself (every tenant's `/query` still executes
|
for the ClickHouse pieces, but this environment lost Docker/database
|
||||||
against one shared ClickHouse connection and Tantivy index; RBAC
|
access partway through the phase, so only the audit-logging guarantees
|
||||||
controls who can query, not what a query can see). Full accounting:
|
were actually confirmed against a live database; the rest is untested
|
||||||
`/docs/security/threat-model.md`; step-by-step verification procedure
|
beyond "compiles, and skips cleanly when no live database is
|
||||||
(not yet run against a live cluster in this environment):
|
configured" (see `/docs/phase-4-runbook.md`'s verification-status
|
||||||
|
section). Two things still keep this phase from being done: SSO login
|
||||||
|
(OIDC/SAML protocol wiring exists, no
|
||||||
|
HTTP login handler calls it), and Tantivy/free-text queries have no
|
||||||
|
per-tenant index routing at all (`enterprise-api` closes the ClickHouse
|
||||||
|
half of tenant isolation, not the Tantivy half) — plus a deployment gap
|
||||||
|
worth naming explicitly: nothing yet forces or even flags whether a
|
||||||
|
given deployment is actually running the isolated binary
|
||||||
|
(`enterprise-api`) versus the plain single-tenant one (`api`); both
|
||||||
|
still exist and nothing currently prevents mixing them up. Full
|
||||||
|
accounting: `/docs/security/threat-model.md`; step-by-step verification
|
||||||
|
procedure (not yet run against a live cluster in this environment):
|
||||||
`/docs/phase-4-runbook.md`. The rest of this section describes the exit
|
`/docs/phase-4-runbook.md`. The rest of this section describes the exit
|
||||||
bar this phase is aiming at, not a completed state.
|
bar this phase is aiming at, not a completed state.
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
|||||||
return id, ok
|
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)
|
return context.WithValue(ctx, identityContextKey{}, id)
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func RequireRole(authorizer Authorizer, minRole Role, next http.HandlerFunc) htt
|
|||||||
writeForbidden(w)
|
writeForbidden(w)
|
||||||
return
|
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)
|
writeForbidden(w)
|
||||||
return
|
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
|
// Command api is Sentry's query API: a single POST /query endpoint
|
||||||
// accepting either the pipe syntax or raw SQL, compiled and routed
|
// accepting either the pipe syntax or raw SQL, compiled and routed
|
||||||
// across ClickHouse and search by internal/querylang. See
|
// 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.
|
// plain REST rather than the pinned gRPC+gateway pattern.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
"github.com/ClickHouse/clickhouse-go/v2"
|
"github.com/ClickHouse/clickhouse-go/v2"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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/config"
|
||||||
"github.com/sentry/sentry/api/internal/dashboards"
|
"github.com/sentry/sentry/api/queryapi"
|
||||||
"github.com/sentry/sentry/api/internal/httpserver"
|
"github.com/sentry/sentry/api/querylang/executor"
|
||||||
"github.com/sentry/sentry/api/internal/queryapi"
|
"github.com/sentry/sentry/api/searchclient"
|
||||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
|
||||||
"github.com/sentry/sentry/api/internal/searchclient"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -104,7 +104,7 @@ func main() {
|
|||||||
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer)
|
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer)
|
||||||
|
|
||||||
// One shared mux, CORS applied once around the whole thing -- see
|
// 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.
|
// handler wrapping itself individually.
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
queryHandler.RegisterRoutes(mux)
|
queryHandler.RegisterRoutes(mux)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"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)
|
// store is the narrow interface Handler depends on -- *Store (store.go)
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// 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
|
// TestServiceIdentityCannotAccessDashboards is the other half of the
|
||||||
// service-identity boundary (the /query half is
|
// 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
|
// already prove RequireRole rejects RoleService in isolation
|
||||||
// (TestRequireRolePlainDoesNotAllowService); this proves it holds
|
// (TestRequireRolePlainDoesNotAllowService); this proves it holds
|
||||||
// through the real dashboards handler, wired the way it's actually
|
// 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 \
|
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
|
||||||
// -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
|
// -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
|
||||||
// -e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \
|
// -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
|
package dashboards
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package httpserver holds cross-handler HTTP concerns for /api. Phase 3
|
// Package httpserver holds cross-handler HTTP concerns for /api. Phase 3
|
||||||
// introduced a second handler package (internal/dashboards) alongside
|
// introduced a second handler package (dashboards) alongside
|
||||||
// internal/queryapi, so CORS moved out of individual handlers into one
|
// queryapi, so CORS moved out of individual handlers into one
|
||||||
// wrap applied around the fully-assembled mux in cmd/api/main.go, rather
|
// wrap applied around the fully-assembled mux in cmd/api/main.go, rather
|
||||||
// than each handler package wrapping itself.
|
// than each handler package wrapping itself.
|
||||||
package httpserver
|
package httpserver
|
||||||
@@ -74,7 +74,7 @@ func (FreeText) isTerm() {}
|
|||||||
type TimeExpr struct {
|
type TimeExpr struct {
|
||||||
Absolute string
|
Absolute string
|
||||||
IsRelative bool
|
IsRelative bool
|
||||||
RelativeSign int // -1 or +1
|
RelativeSign int // -1 or +1
|
||||||
RelativeN int
|
RelativeN int
|
||||||
RelativeUnit string // "s" | "m" | "h" | "d" | "w"
|
RelativeUnit string // "s" | "m" | "h" | "d" | "w"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,23 +12,23 @@ type Kind int
|
|||||||
const (
|
const (
|
||||||
EOF Kind = iota
|
EOF Kind = iota
|
||||||
Illegal
|
Illegal
|
||||||
Ident // bare words: field names, keywords, unquoted values/free-text terms
|
Ident // bare words: field names, keywords, unquoted values/free-text terms
|
||||||
String // quoted string: "..."
|
String // quoted string: "..."
|
||||||
Number // 123, 1.5
|
Number // 123, 1.5
|
||||||
Pipe // |
|
Pipe // |
|
||||||
Eq // =
|
Eq // =
|
||||||
Neq // !=
|
Neq // !=
|
||||||
Gt // >
|
Gt // >
|
||||||
Gte // >=
|
Gte // >=
|
||||||
Lt // <
|
Lt // <
|
||||||
Lte // <=
|
Lte // <=
|
||||||
Colon // :
|
Colon // :
|
||||||
Comma // ,
|
Comma // ,
|
||||||
LParen // (
|
LParen // (
|
||||||
RParen // )
|
RParen // )
|
||||||
Minus // -
|
Minus // -
|
||||||
Plus // +
|
Plus // +
|
||||||
Star // * (only meaningful inside count(*), same as SQL)
|
Star // * (only meaningful inside count(*), same as SQL)
|
||||||
)
|
)
|
||||||
|
|
||||||
type Token struct {
|
type Token struct {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
type Language string
|
type Language string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Auto Language = "" // detect from the query text (default)
|
Auto Language = "" // detect from the query text (default)
|
||||||
SQL Language = "sql"
|
SQL Language = "sql"
|
||||||
SPL Language = "spl" // the pipe syntax; named to match the query-language-reference doc
|
SPL Language = "spl" // the pipe syntax; named to match the query-language-reference doc
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sentry/sentry/api/internal/authz"
|
"github.com/sentry/sentry/api/authz"
|
||||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
|
||||||
"github.com/sentry/sentry/api/internal/querylang/planner"
|
"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 --
|
// 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
|
// 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
|
// now applied once, by main.go, around the fully-assembled mux rather
|
||||||
// than by each handler wrapping itself individually -- see
|
// than by each handler wrapping itself individually -- see
|
||||||
// httpserver.WithCORS.
|
// httpserver.WithCORS.
|
||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sentry/sentry/api/internal/authz"
|
"github.com/sentry/sentry/api/authz"
|
||||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
"github.com/sentry/sentry/api/querylang/executor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeSQLRunner struct {
|
type fakeSQLRunner struct {
|
||||||
+20
-28
@@ -1,38 +1,30 @@
|
|||||||
// This file is a checklist, not a passing test suite -- it exists so
|
// This file is a checklist, not a fully passing test suite -- it exists
|
||||||
// the four adversarial probes /docs/phase-4-isolation-design.md's
|
// so the four adversarial probes /docs/phase-4-isolation-design.md's
|
||||||
// "Verification plan for this design specifically" section names for
|
// "Verification plan for this design specifically" section names for
|
||||||
// Phase 4 task 8 have a permanent, grep-able home in the test tree,
|
// Phase 4 task 8 have a permanent, grep-able home in the test tree.
|
||||||
// even though none of them can run for real yet.
|
|
||||||
//
|
//
|
||||||
// Why they can't run: every one of these probes needs a *per-tenant*
|
// Item 1 (fully-qualified cross-tenant raw SQL) is no longer blocked:
|
||||||
// ClickHouse user/database or Tantivy index to attack -- and none
|
// enterprise/internal/tenantprovision and enterprise/internal/chrunner
|
||||||
// exist. api/internal/querylang/executor.SQLRunner/SearchClient (the
|
// now exist, and both have real, passing (when run against a live
|
||||||
// only two interfaces api/internal/queryapi.Handler talks to) carry no
|
// ClickHouse) tests for exactly this probe --
|
||||||
// tenant field at all, confirmed by reading both interfaces; neither
|
// enterprise/internal/tenantprovision/tenantprovision_test.go's
|
||||||
// does proto/sentry/search/v1/search.proto's SearchRequest. See
|
// TestProvisionedUserCannotReadOtherTenantDatabase (at the raw
|
||||||
// /docs/security/threat-model.md's "Read this first" section for the
|
// ClickHouse-user layer) and enterprise/internal/chrunner/
|
||||||
// full writeup -- there is currently exactly one shared ClickHouse
|
// chrunner_test.go's TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL
|
||||||
// connection and one shared Tantivy index for every tenant, so "does
|
// (through the actual query-execution code path api/queryapi.Handler
|
||||||
// tenant A's connection leak tenant B's data" has no meaningful
|
// calls in production, when fronted by enterprise/cmd/enterprise-api
|
||||||
// operational answer yet: there's only one connection.
|
// 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
|
// Items 2-4 remain blocked, for the reasons each Skip below states.
|
||||||
// can be written for real (enterprise/internal/tenantprovision,
|
// Note the scope boundary this leaves: even with chrunner wired in,
|
||||||
// enterprise/internal/chrunner, enterprise/internal/searchclient -- all
|
// there is still exactly one shared Tantivy index for every tenant
|
||||||
// still unbuilt, per the Phase 4 task 5 summary). Turning a Skip here
|
// (enterprise/internal/searchclient, the Tantivy-side equivalent of
|
||||||
// into a real assertion is the acceptance criterion for those packages,
|
// chrunner, is unbuilt) -- see /docs/security/threat-model.md.
|
||||||
// not a nice-to-have follow-up.
|
|
||||||
package queryapi
|
package queryapi
|
||||||
|
|
||||||
import "testing"
|
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) {
|
func TestAdversarial_ClickHouseUserCannotReadSystemTables(t *testing.T) {
|
||||||
t.Skip("BLOCKED on enterprise/internal/tenantprovision: needs a real per-tenant " +
|
t.Skip("BLOCKED on enterprise/internal/tenantprovision: needs a real per-tenant " +
|
||||||
"ClickHouse user to attempt `SELECT * FROM system.query_log`, " +
|
"ClickHouse user to attempt `SELECT * FROM system.query_log`, " +
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
// ClickHouse and shapes the result into JSON-friendly columns/rows,
|
// ClickHouse and shapes the result into JSON-friendly columns/rows,
|
||||||
// discovering the result's column set at query time via reflection since
|
// discovering the result's column set at query time via reflection since
|
||||||
// the query itself is arbitrary. Ported from Phase 0/1's
|
// 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
|
// same logic, moved here since it's the query-execution layer's
|
||||||
// plumbing, not specific to the old placeholder /query handler.
|
// plumbing, not specific to the old placeholder /query handler.
|
||||||
type ChRunner struct {
|
type ChRunner struct {
|
||||||
@@ -46,7 +46,7 @@ spec:
|
|||||||
key: password
|
key: password
|
||||||
{{- if .Values.enterprise.enabled }}
|
{{- if .Values.enterprise.enabled }}
|
||||||
# Turns on authz.RequireRole*/RequireRoleOrService enforcement
|
# Turns on authz.RequireRole*/RequireRoleOrService enforcement
|
||||||
# on /query and /dashboards -- see api/internal/authz and
|
# on /query and /dashboards -- see api/authz and
|
||||||
# /docs/phase-4-rbac-design.md. Off (unset) when
|
# /docs/phase-4-rbac-design.md. Off (unset) when
|
||||||
# enterprise.enabled is false, matching every nil-authorizer
|
# enterprise.enabled is false, matching every nil-authorizer
|
||||||
# no-op default in this codebase.
|
# no-op default in this codebase.
|
||||||
|
|||||||
@@ -264,6 +264,46 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
|
# Multi-tenant-aware alternative to `api` (Phase 4) -- see
|
||||||
|
# enterprise/cmd/enterprise-api/main.go's doc comment for why this is
|
||||||
|
# a second binary rather than a flag on `api`. NOT part of the default
|
||||||
|
# traffic path: `web`'s VITE_API_BASE_URL still points at `api`
|
||||||
|
# (localhost:8080), and nothing here provisions any tenants (see that
|
||||||
|
# binary's -provision-tenant flag) -- included so it can be
|
||||||
|
# built/run/curled directly, same "available, not defaulted in" shape
|
||||||
|
# as enterprise-auth above. CLICKHOUSE_ADMIN_USERNAME/PASSWORD reuse
|
||||||
|
# the same admin credential `clickhouse-migrate` uses, since
|
||||||
|
# tenantprovision needs access_management, not a tenant-scoped grant.
|
||||||
|
enterprise-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: enterprise/cmd/enterprise-api/Dockerfile
|
||||||
|
container_name: sentry-enterprise-api
|
||||||
|
depends_on:
|
||||||
|
clickhouse-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
metadata-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
ports:
|
||||||
|
- "8083:8083"
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_ADDR: "clickhouse:9000"
|
||||||
|
CLICKHOUSE_ADMIN_USERNAME: "default"
|
||||||
|
CLICKHOUSE_ADMIN_PASSWORD: "sentry-dev-only"
|
||||||
|
SEARCH_GRPC_ADDR: "search:50052"
|
||||||
|
POSTGRES_ADDR: "metadata-postgres:5432"
|
||||||
|
POSTGRES_DATABASE: "sentry_metadata"
|
||||||
|
POSTGRES_USERNAME: "sentry"
|
||||||
|
POSTGRES_PASSWORD: "sentry-dev-only"
|
||||||
|
AUDIT_WRITER_USERNAME: "audit_writer"
|
||||||
|
AUDIT_WRITER_PASSWORD: "audit-writer-dev-only"
|
||||||
|
ENTERPRISE_AUTH_URL: "http://enterprise-auth:8082"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/enterprise-api", "-healthcheck"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: web
|
context: web
|
||||||
|
|||||||
+37
-21
@@ -79,7 +79,7 @@ This split is not to be changed without discussion — see CLAUDE.md.
|
|||||||
| `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. |
|
| `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. |
|
| `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). |
|
| `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`. |
|
| `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`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Does **not** yet include per-tenant Tantivy 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. |
|
| `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). |
|
| `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. |
|
| `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. |
|
||||||
@@ -101,32 +101,48 @@ through a tenant-scoped connection the database's own access control
|
|||||||
enforces — not at the query-compiler layer, since Phase 2's raw-SQL
|
enforces — not at the query-compiler layer, since Phase 2's raw-SQL
|
||||||
escape hatch is opaque to any compiler-injected filter.
|
escape hatch is opaque to any compiler-injected filter.
|
||||||
|
|
||||||
**As built, through Phase 4 task 8:**
|
**As built, currently:**
|
||||||
|
|
||||||
- Role-based access control (`api/internal/authz`) is live on `/query`
|
- Role-based access control (`api/authz`) is live on `/query`
|
||||||
and `/dashboards`, resolved via `enterprise-auth` over HTTP.
|
and `/dashboards`, resolved via `enterprise-auth` over HTTP.
|
||||||
- Control-plane tenant scoping is live for dashboards
|
- Control-plane tenant scoping is live for dashboards
|
||||||
(`api/internal/dashboards`'s store filters every query by the
|
(`api/dashboards`'s store filters every query by the
|
||||||
authenticated identity's tenant, never a client-supplied field).
|
authenticated identity's tenant, never a client-supplied field).
|
||||||
- The `alerting`↔`api` service-identity gap (task 2's finding) is
|
- The `alerting`↔`api` service-identity gap (task 2's finding) is
|
||||||
closed: a `RoleService` credential, distinct from every human role.
|
closed: a `RoleService` credential, distinct from every human role.
|
||||||
- **The connection-layer isolation itself — the actual design above —
|
- **ClickHouse connection-layer isolation is built**, but lives in a
|
||||||
is not built.** `api/internal/querylang/executor.SQLRunner`/
|
second binary: `enterprise/internal/tenantprovision` (real `CREATE
|
||||||
`SearchClient` and `search`'s gRPC service carry no tenant field
|
DATABASE`/`CREATE USER`/`GRANT` against ClickHouse) and
|
||||||
anywhere. There is one shared ClickHouse connection and one shared
|
`enterprise/internal/chrunner` (a per-tenant connection registry
|
||||||
Tantivy index for every tenant. RBAC controls *who* can run a query;
|
implementing `api/querylang/executor.SQLRunner`, resolving the
|
||||||
nothing yet controls *what data* that query can see.
|
right tenant's connection from the authenticated identity in request
|
||||||
- `deploy/operator`'s `Tenant` CRD manages only the K8s-side artifact (a
|
context) are wired into `enterprise/cmd/enterprise-api` — a binary
|
||||||
credential Secret) — it doesn't call ClickHouse or provision anything
|
that imports both `api`'s handler packages and enterprise's
|
||||||
ClickHouse-side. `enterprise/internal/tenantprovision` (the piece that
|
tenant-aware implementations (the allowed `enterprise → api` import
|
||||||
would) is unbuilt.
|
direction; core still never imports `enterprise/`). Real integration
|
||||||
|
tests assert a tenant cannot read another tenant's database by
|
||||||
|
fully-qualified name, and that `system.query_log`/`system.tables`/
|
||||||
|
`SHOW DATABASES` don't leak across tenants either — written but not
|
||||||
|
yet run against a live ClickHouse in this environment, see
|
||||||
|
`/docs/security/threat-model.md` and `/docs/phase-4-runbook.md`'s
|
||||||
|
verification-status sections. Plain `api/cmd/api` still exists,
|
||||||
|
unchanged, with its single shared connection — nothing forces a
|
||||||
|
deployment to run `enterprise-api` instead, and nothing flags it if it
|
||||||
|
doesn't.
|
||||||
|
- **Tantivy connection-layer isolation is not built.** `search`'s gRPC
|
||||||
|
service and `proto/sentry/search/v1/search.proto`'s `SearchRequest`
|
||||||
|
still carry no tenant field. Every tenant's free-text queries hit the
|
||||||
|
same shared Tantivy index regardless of which binary serves the
|
||||||
|
request.
|
||||||
|
- `deploy/operator`'s `Tenant` CRD still manages only the K8s-side
|
||||||
|
artifact (a credential Secret); the Helm chart has no service
|
||||||
|
definition for `enterprise-api` yet.
|
||||||
|
|
||||||
Building `enterprise/internal/chrunner` + `internal/searchclient` (the
|
Building `enterprise/internal/searchclient` (the Tantivy-side sibling of
|
||||||
tenant-scoped implementations of the two interfaces above) and wiring
|
`chrunner`) and giving the deployment topology (Helm chart, or at least
|
||||||
them into `api/internal/queryapi.Handler` in place of the single shared
|
clear documentation) an actual way to route traffic to `enterprise-api`
|
||||||
connection `api/cmd/api/main.go` opens today is the single largest
|
instead of `api` are the two largest remaining gaps between this system
|
||||||
remaining gap between this system and the isolation model it was
|
and the isolation model it was designed to have.
|
||||||
designed to have.
|
|
||||||
|
|
||||||
## Licensing boundary
|
## Licensing boundary
|
||||||
|
|
||||||
@@ -137,7 +153,7 @@ CI by `hack/check-tenant-boundary.sh`, which greps every build for the
|
|||||||
import edge. Where core needs a decision only `enterprise/` can make
|
import edge. Where core needs a decision only `enterprise/` can make
|
||||||
(is this request authorized, what SSO is configured), it calls
|
(is this request authorized, what SSO is configured), it calls
|
||||||
`enterprise-auth` over plain HTTP instead
|
`enterprise-auth` over plain HTTP instead
|
||||||
(`api/internal/authz.HTTPAuthorizer`, `web`'s `GET /auth/features`) —
|
(`api/authz.HTTPAuthorizer`, `web`'s `GET /auth/features`) —
|
||||||
the same "network boundary, not import boundary" shape `/alerting`↔`api`
|
the same "network boundary, not import boundary" shape `/alerting`↔`api`
|
||||||
already used before `enterprise/` existed.
|
already used before `enterprise/` existed.
|
||||||
|
|
||||||
|
|||||||
+84
-33
@@ -8,28 +8,33 @@ logging, and a Kubernetes deployment path. Read those first.
|
|||||||
|
|
||||||
Every prior phase's runbook documents claims **checked against the live
|
Every prior phase's runbook documents claims **checked against the live
|
||||||
stack**, not asserted. This one is different, and says so plainly rather
|
stack**, not asserted. This one is different, and says so plainly rather
|
||||||
than papering over it: **this session had no working Docker daemon
|
than papering over it: for the great majority of this phase's work,
|
||||||
access and no reachable Kubernetes cluster**, so most of what follows is
|
**there was no working Docker daemon access and no reachable Kubernetes
|
||||||
a *procedure to run*, not a report of what was already run and passed.
|
cluster**, so most of what follows is a *procedure to run*, not a report
|
||||||
Two exceptions, genuinely verified live against a real Postgres during
|
of what was already run and passed. One genuine exception, verified live
|
||||||
earlier Phase 4 tasks (see their own doc comments for the exact `docker
|
against a real Postgres earlier in this phase's work (see its own doc
|
||||||
run` invocations):
|
comments for the exact `docker run` invocations, and note this was
|
||||||
|
before the environment lost Docker access, not a claim about this
|
||||||
|
runbook's own session):
|
||||||
|
|
||||||
- `enterprise/internal/audit`'s hash-chain, tamper-detection, and
|
- `enterprise/internal/audit`'s hash-chain, tamper-detection, and
|
||||||
concurrent-write guarantees (task 4).
|
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
|
Everything else — `internal/rbacstore`'s CRUD, the auth-enforcement
|
||||||
tenant-scoping fix, the Helm chart, the tenant-operator — has unit/fake-
|
walkthrough, the dashboards tenant-scoping fix, the Helm chart, the
|
||||||
client/`helm template` coverage (all passing, see each component's own
|
tenant-operator, and (newest) `internal/tenantprovision`/
|
||||||
`go test`/`helm lint` output) but has **not** been exercised against a
|
`internal/chrunner`'s live-ClickHouse tests — has unit/fake-client/
|
||||||
real running stack in this session. If you're reading this to decide
|
`helm template` coverage (all passing, see each component's own `go
|
||||||
whether Phase 4 is production-ready: it isn't yet, independent of this
|
test`/`helm lint` output, including every `Skip*`-gated integration test
|
||||||
gap — see `/docs/security/threat-model.md`'s headline finding (log-data
|
confirmed to skip cleanly offline) but has **not** been exercised
|
||||||
query isolation isn't built). This runbook exists so the first person
|
against a real running stack. Be specific when citing this runbook: "the
|
||||||
with real Docker/K8s access can actually close the loop, not to claim
|
tests exist and pass structurally" is a true, verified claim; "isolation
|
||||||
that already happened.
|
was confirmed against real ClickHouse" is not, yet. 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. 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
|
## 1. Bring up the stack
|
||||||
|
|
||||||
@@ -172,28 +177,74 @@ kubectl get secret sentry-tenant-acme-clickhouse -o yaml
|
|||||||
Expect `kubectl get tenants` to show `acme` reach `status.phase: Active`
|
Expect `kubectl get tenants` to show `acme` reach `status.phase: Active`
|
||||||
and the Secret to contain a generated `username`/`password`/`database`.
|
and the Secret to contain a generated `username`/`password`/`database`.
|
||||||
This proves the K8s-side half of a real two-tenant deployment — it does
|
This proves the K8s-side half of a real two-tenant deployment — it does
|
||||||
**not** prove either tenant has a working ClickHouse database, since
|
**not** provision a working ClickHouse database itself (the Operator
|
||||||
`enterprise/internal/tenantprovision` (the piece that would create one)
|
manages the K8s Secret only); §8 below is the piece that actually
|
||||||
isn't built. See `/deploy/README.md` and
|
provisions ClickHouse.
|
||||||
`/docs/security/threat-model.md`.
|
|
||||||
|
## 8. `enterprise-api`: real per-tenant ClickHouse isolation
|
||||||
|
|
||||||
|
This is new since this runbook was first written — `enterprise/internal/
|
||||||
|
tenantprovision` and `enterprise/internal/chrunner` now exist, closing
|
||||||
|
the headline gap §"Known gaps" below used to describe as completely
|
||||||
|
unbuilt. It's still a second binary you have to choose to run, though —
|
||||||
|
see `/docs/security/threat-model.md`'s "Read this first" section.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose build enterprise-api
|
||||||
|
docker compose run --rm enterprise-api -provision-tenant=acme -display-name="Acme Corp"
|
||||||
|
docker compose run --rm enterprise-api -provision-tenant=globex -display-name="Globex Corporation"
|
||||||
|
docker compose up -d enterprise-api
|
||||||
|
curl -s http://localhost:8083/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
There's still no OIDC/SAML login handler and no CLI for minting a human
|
||||||
|
session token (see `/docs/security/threat-model.md`) -- so a real
|
||||||
|
`curl -X POST http://localhost:8083/query` walkthrough as tenant acme
|
||||||
|
isn't possible yet. Confirm isolation end to end against the live stack (this is the same
|
||||||
|
assertion `enterprise/internal/chrunner/chrunner_test.go`'s
|
||||||
|
`TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL` makes, run here
|
||||||
|
as an integration test instead of a curl walkthrough since there's no
|
||||||
|
login flow to drive it through curl yet):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
|
||||||
|
-e CHRUNNER_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
|
||||||
|
-e CHRUNNER_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
|
||||||
|
golang:1.25-alpine go test ./internal/chrunner/... -v
|
||||||
|
|
||||||
|
docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
|
||||||
|
-e TENANTPROVISION_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
|
||||||
|
-e TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
|
||||||
|
golang:1.25-alpine go test ./internal/tenantprovision/... -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect all tests to pass, including
|
||||||
|
`TestProvisionedUserCannotReadSystemTables` (item 2 of
|
||||||
|
`/docs/phase-4-isolation-design.md`'s verification plan, closed this
|
||||||
|
pass) and `TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL` (item 1,
|
||||||
|
closed through the actual production code path, not just
|
||||||
|
tenantprovision's raw grants).
|
||||||
|
|
||||||
## Known gaps (do not treat this phase as done without reading these)
|
## Known gaps (do not treat this phase as done without reading these)
|
||||||
|
|
||||||
Full accounting: `/docs/security/threat-model.md`. Headline items:
|
Full accounting: `/docs/security/threat-model.md`. Headline items:
|
||||||
|
|
||||||
- **No tenant isolation on log data.** `POST /query` executes against
|
- **ClickHouse isolation exists but is opt-in.** `enterprise-api`
|
||||||
one shared ClickHouse connection and one shared Tantivy index for
|
(§8) gives real per-tenant ClickHouse isolation, but plain `api`
|
||||||
every tenant, regardless of RBAC. This is Phase 4's originally-stated
|
(still the default in `docker-compose.yml`/`web`'s base URL) has none,
|
||||||
highest-risk item and it is not resolved.
|
and nothing flags which one a given deployment is actually running.
|
||||||
|
- **No Tantivy/free-text isolation at all**, regardless of which binary
|
||||||
|
serves the request -- `enterprise/internal/searchclient` (chrunner's
|
||||||
|
Tantivy-side sibling) doesn't exist.
|
||||||
- **No human SSO login.** OIDC/SAML protocol wiring exists;
|
- **No human SSO login.** OIDC/SAML protocol wiring exists;
|
||||||
the HTTP login/callback handlers that would use it don't.
|
the HTTP login/callback handlers that would use it don't.
|
||||||
- **No per-resource dashboard grants** (`dashboard_permissions` has a
|
- **No per-resource dashboard grants** (`dashboard_permissions` has a
|
||||||
schema, no handler reads it).
|
schema, no handler reads it).
|
||||||
- Four adversarial ClickHouse/Tantivy probes named in
|
- Two of the four adversarial ClickHouse/Tantivy probes named in
|
||||||
`/docs/phase-4-isolation-design.md`'s verification plan are stubbed as
|
`/docs/phase-4-isolation-design.md`'s verification plan are closed
|
||||||
explicitly-skipped tests in `api/internal/queryapi/
|
(§8); the other two (Tantivy cross-tenant search, mid-provisioning-race
|
||||||
tenant_isolation_gap_test.go`, blocked on the tenant-scoped connection
|
handling) are still stubbed as explicitly-skipped tests in
|
||||||
work above.
|
`api/queryapi/tenant_isolation_gap_test.go`.
|
||||||
|
|
||||||
## Tearing down
|
## Tearing down
|
||||||
|
|
||||||
@@ -214,13 +265,13 @@ be too.
|
|||||||
set.**
|
set.**
|
||||||
Check `api/cmd/api/main.go` actually left `authorizer` nil when
|
Check `api/cmd/api/main.go` actually left `authorizer` nil when
|
||||||
`cfg.EnterpriseAuthURL == ""` — a nil `Authorizer` must be a no-op
|
`cfg.EnterpriseAuthURL == ""` — a nil `Authorizer` must be a no-op
|
||||||
(`api/internal/authz.RequireRole`'s doc comment). If this regresses, it
|
(`api/authz.RequireRole`'s doc comment). If this regresses, it
|
||||||
breaks every existing Phase 0-3 deployment silently.
|
breaks every existing Phase 0-3 deployment silently.
|
||||||
|
|
||||||
**A dashboard created by one tenant is visible to another.**
|
**A dashboard created by one tenant is visible to another.**
|
||||||
This is the exact bug found and fixed in task 7 — see
|
This is the exact bug found and fixed in task 7 — see
|
||||||
`/docs/security/threat-model.md`'s "application-layer tenant scoping"
|
`/docs/security/threat-model.md`'s "application-layer tenant scoping"
|
||||||
section and `api/internal/dashboards/handler_test.go`'s
|
section and `api/dashboards/handler_test.go`'s
|
||||||
`TestCrossTenant*` tests. If this regresses, `Handler.tenantID` or
|
`TestCrossTenant*` tests. If this regresses, `Handler.tenantID` or
|
||||||
`store.go`'s `WHERE tenant_id = ...` filters have been bypassed
|
`store.go`'s `WHERE tenant_id = ...` filters have been bypassed
|
||||||
somewhere — check every store method still takes and uses a `tenantID`
|
somewhere — check every store method still takes and uses a `tenantID`
|
||||||
|
|||||||
@@ -9,28 +9,58 @@ for the full design rationale behind the controls described here.
|
|||||||
|
|
||||||
## Read this first: the single most important open finding
|
## Read this first: the single most important open finding
|
||||||
|
|
||||||
**Log data queried through `POST /query` is not tenant-isolated today.**
|
**Updated**: this section originally read "log data queried through
|
||||||
Every authenticated tenant's ad hoc queries and dashboard panel queries
|
`POST /query` is not tenant-isolated at all." That's now only half
|
||||||
execute against the same shared ClickHouse connection and the same
|
true, and the half that's no longer true matters — read carefully,
|
||||||
shared Tantivy index — there is no per-tenant database, user, or index
|
because the remaining gap (Tantivy/free-text) is easy to miss if you
|
||||||
routing anywhere in the query execution path
|
stop at "ClickHouse is isolated now."
|
||||||
(`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`
|
**ClickHouse (the SQL path) is now built, but only if you run the right
|
||||||
specifies as the core deliverable of tenant isolation (one dedicated
|
binary — and it has not yet been confirmed against a real ClickHouse.**
|
||||||
ClickHouse database/user and one dedicated Tantivy index directory per
|
`enterprise/internal/tenantprovision` (real `CREATE DATABASE`/`CREATE
|
||||||
tenant) — it is **designed but not built**. What *is* built and live:
|
USER`/`GRANT` against ClickHouse) and `enterprise/internal/chrunner` (a
|
||||||
role-based access control (below) and tenant-scoped control-plane data
|
per-tenant `driver.Conn` registry implementing api's
|
||||||
(dashboards, below). Until `enterprise/internal/chrunner` and
|
`querylang/executor.SQLRunner`, resolving which tenant's connection to
|
||||||
`enterprise/internal/searchclient` exist and are wired into
|
use from the authenticated identity in request context — never from a
|
||||||
`api/internal/queryapi.Handler` in place of the single shared connection
|
client-suppliable field) now exist, and a new binary,
|
||||||
`api/cmd/api/main.go` opens today, **treat any deployment of this system
|
`enterprise/cmd/enterprise-api`, wires them into the same
|
||||||
as single-tenant only**, regardless of how many `Tenant` CRs or
|
`api/queryapi.Handler`/`api/dashboards.Handler` core already ships. Real
|
||||||
`tenant_memberships` rows exist. RBAC controls who can run a query; they
|
integration tests exist and would prove the core adversarial claim —
|
||||||
do not control what data that query can see.
|
`enterprise/internal/tenantprovision/tenantprovision_test.go`'s
|
||||||
|
`TestProvisionedUserCannotReadOtherTenantDatabase` and
|
||||||
|
`TestProvisionedUserCannotReadSystemTables`,
|
||||||
|
`enterprise/internal/chrunner/chrunner_test.go`'s
|
||||||
|
`TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL` — but this
|
||||||
|
environment had no Docker/ClickHouse access while these were written, so
|
||||||
|
they've only been confirmed to skip cleanly offline, not to pass for
|
||||||
|
real. See `/docs/phase-4-runbook.md`'s verification-status section
|
||||||
|
before treating "the test exists" as "isolation is confirmed."
|
||||||
|
|
||||||
|
**But plain `api/cmd/api` still runs with one shared connection**, and
|
||||||
|
nothing in this repo automatically routes traffic to `enterprise-api`
|
||||||
|
instead — `docker-compose.yml` includes it "available, not defaulted
|
||||||
|
into the traffic path" (same shape as `enterprise-auth`'s own addition),
|
||||||
|
and the Helm chart has no service for it at all yet. **A deployment is
|
||||||
|
only as isolated as which binary is actually serving traffic** — this
|
||||||
|
is an operational decision nothing currently enforces or even surfaces
|
||||||
|
as a warning.
|
||||||
|
|
||||||
|
**Tantivy (the free-text path) is still fully unisolated.** There is no
|
||||||
|
`enterprise/internal/searchclient` (the Tantivy-side equivalent of
|
||||||
|
chrunner) — `search`'s gRPC service and
|
||||||
|
`proto/sentry/search/v1/search.proto`'s `SearchRequest` still carry no
|
||||||
|
tenant field anywhere, confirmed by reading the code. Every tenant's
|
||||||
|
free-text queries hit the same shared Tantivy index regardless of which
|
||||||
|
binary (`api` or `enterprise-api`) serves the HTTP request. A query that
|
||||||
|
resolves to a pure pipe-syntax free-text search (e.g. `message:"error"`)
|
||||||
|
is not protected by chrunner at all.
|
||||||
|
|
||||||
|
**What this means concretely**: treat a deployment as tenant-isolated
|
||||||
|
for structured/SQL queries *only if* it runs `enterprise-api` fronting
|
||||||
|
provisioned tenants, and treat it as **not isolated at all** for
|
||||||
|
free-text search regardless of which binary runs. RBAC (below) and
|
||||||
|
dashboard tenant-scoping (below) hold regardless of which binary is
|
||||||
|
running; the ClickHouse/Tantivy split above is what changed.
|
||||||
|
|
||||||
## System overview
|
## System overview
|
||||||
|
|
||||||
@@ -38,12 +68,19 @@ do not control what data that query can see.
|
|||||||
Browser ──▶ web (SvelteKit, static)
|
Browser ──▶ web (SvelteKit, static)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Browser ──▶ api ──▶ ClickHouse (log data, SQL path)
|
Browser ──▶ api OR enterprise-api ──▶ ClickHouse (log data, SQL path)
|
||||||
│ └─▶ search (gRPC) ──▶ Tantivy (log data, full-text path)
|
│ └─▶ search (gRPC) ──▶ Tantivy (log data, full-text path)
|
||||||
└─▶ Postgres (control plane: dashboards, alert_rules,
|
└─▶ Postgres (control plane: dashboards, alert_rules,
|
||||||
tenants, users, tenant_memberships, audit_log)
|
tenants, users, tenant_memberships, audit_log)
|
||||||
|
|
||||||
alerting ──▶ api (POST /query, RoleService credential)
|
# api: one shared ClickHouse connection, nil AuditLogger -- Phase 0-3 behavior.
|
||||||
|
# enterprise-api: enterprise/internal/chrunner (per-tenant ClickHouse
|
||||||
|
# connections) + enterprise/internal/audit.QueryAPILogger (real audit
|
||||||
|
# writes) wired into the SAME api/queryapi.Handler/api/dashboards.Handler
|
||||||
|
# core -- see this document's "Read this first" section. Either binary
|
||||||
|
# can be running; nothing forces the isolated one.
|
||||||
|
|
||||||
|
alerting ──▶ api or enterprise-api (POST /query, RoleService credential)
|
||||||
alerting ──▶ Postgres (rulestore, notifystore)
|
alerting ──▶ Postgres (rulestore, notifystore)
|
||||||
|
|
||||||
api/alerting ──▶ enterprise-auth (POST /internal/authorize, HTTP only —
|
api/alerting ──▶ enterprise-auth (POST /internal/authorize, HTTP only —
|
||||||
@@ -67,9 +104,9 @@ doesn't yet cover, not just an implementation gap.
|
|||||||
session issuance) is never imported by AGPL core (`/api`, `/alerting`,
|
session issuance) is never imported by AGPL core (`/api`, `/alerting`,
|
||||||
`/web`, `/cli`) — enforced in CI by `hack/check-tenant-boundary.sh`,
|
`/web`, `/cli`) — enforced in CI by `hack/check-tenant-boundary.sh`,
|
||||||
which greps for the import edge on every build. Core calls
|
which greps for the import edge on every build. Core calls
|
||||||
`enterprise-auth` over plain HTTP (`api/internal/authz.HTTPAuthorizer`),
|
`enterprise-auth` over plain HTTP (`api/authz.HTTPAuthorizer`),
|
||||||
forwarding only the `Cookie`/`Authorization` headers, never the full
|
forwarding only the `Cookie`/`Authorization` headers, never the full
|
||||||
request (`api/internal/authz/httpauthz_test.go` asserts this — an
|
request (`api/authz/httpauthz_test.go` asserts this — an
|
||||||
unrelated header like `X-Forwarded-For` is never forwarded). This means
|
unrelated header like `X-Forwarded-For` is never forwarded). This means
|
||||||
core's authorization decision is only as trustworthy as the network path
|
core's authorization decision is only as trustworthy as the network path
|
||||||
to `enterprise-auth` — see "Deployment/network assumptions" below.
|
to `enterprise-auth` — see "Deployment/network assumptions" below.
|
||||||
@@ -95,9 +132,9 @@ a network-reachable endpoint) and configured via `API_SERVICE_TOKEN`.
|
|||||||
`enterprise/internal/session.Manager` issues and validates this token;
|
`enterprise/internal/session.Manager` issues and validates this token;
|
||||||
`enterprise/internal/authhandler`'s `POST /internal/authorize` resolves
|
`enterprise/internal/authhandler`'s `POST /internal/authorize` resolves
|
||||||
it. `RoleService` is a distinct, non-comparable lane on the `Role` type
|
it. `RoleService` is a distinct, non-comparable lane on the `Role` type
|
||||||
(`api/internal/authz.Role.Satisfies`) — a service credential can never
|
(`api/authz.Role.Satisfies`) — a service credential can never
|
||||||
satisfy a human-role check and vice versa, verified by exhaustive
|
satisfy a human-role check and vice versa, verified by exhaustive
|
||||||
table-driven tests (`api/internal/authz/authz_test.go`).
|
table-driven tests (`api/authz/authz_test.go`).
|
||||||
|
|
||||||
**Session/token integrity.** Tokens are HS256-signed JWTs with a single
|
**Session/token integrity.** Tokens are HS256-signed JWTs with a single
|
||||||
shared signing key (`ENTERPRISE_SESSION_SIGNING_KEY`, ≥32 bytes,
|
shared signing key (`ENTERPRISE_SESSION_SIGNING_KEY`, ≥32 bytes,
|
||||||
@@ -115,10 +152,10 @@ mode — bad signature, malformed token, expired — into one
|
|||||||
|
|
||||||
**Live and enforced.** `POST /query` and every `/dashboards` endpoint in
|
**Live and enforced.** `POST /query` and every `/dashboards` endpoint in
|
||||||
`api` require a minimum role, resolved per-request via
|
`api` require a minimum role, resolved per-request via
|
||||||
`api/internal/authz.RequireRole`/`RequireRoleOrService` calling
|
`api/authz.RequireRole`/`RequireRoleOrService` calling
|
||||||
`enterprise-auth`. Roles: Viewer < Editor < Admin < Owner, plus the
|
`enterprise-auth`. Roles: Viewer < Editor < Admin < Owner, plus the
|
||||||
separate `RoleService` lane above. `GET /dashboards` is Viewer+;
|
separate `RoleService` lane above. `GET /dashboards` is Viewer+;
|
||||||
create/update/delete require Editor+ (`api/internal/dashboards/
|
create/update/delete require Editor+ (`api/dashboards/
|
||||||
handler.go`). A nil `Authorizer` (no `ENTERPRISE_AUTH_URL` configured)
|
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
|
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
|
correct default-open-for-single-tenant behavior, not an oversight, but
|
||||||
@@ -135,12 +172,12 @@ dashboard in that tenant, not just their own/granted ones.
|
|||||||
|
|
||||||
**Application-layer tenant scoping (dashboards only).** Every
|
**Application-layer tenant scoping (dashboards only).** Every
|
||||||
`dashboards` store query filters `WHERE tenant_id = $identity.TenantID`
|
`dashboards` store query filters `WHERE tenant_id = $identity.TenantID`
|
||||||
(`api/internal/dashboards/store.go`), and the handler resolves that
|
(`api/dashboards/store.go`), and the handler resolves that
|
||||||
tenant ID from the RBAC-authenticated identity's context
|
tenant ID from the RBAC-authenticated identity's context
|
||||||
(`authz.IdentityFromContext`), **never** from a client-supplied request
|
(`authz.IdentityFromContext`), **never** from a client-supplied request
|
||||||
field. This closes a real gap found during this document's own review:
|
field. This closes a real gap found during this document's own review:
|
||||||
`Dashboard.TenantID` is a JSON-tagged, client-settable field
|
`Dashboard.TenantID` is a JSON-tagged, client-settable field
|
||||||
(`api/internal/dashboards/types.go`), and the original handler/store
|
(`api/dashboards/types.go`), and the original handler/store
|
||||||
implementation trusted it directly on create/update and applied no
|
implementation trusted it directly on create/update and applied no
|
||||||
`tenant_id` filter at all on list/get/update/delete — meaning any
|
`tenant_id` filter at all on list/get/update/delete — meaning any
|
||||||
authenticated user could read, modify, or delete any other tenant's
|
authenticated user could read, modify, or delete any other tenant's
|
||||||
@@ -149,7 +186,7 @@ dashboards simply by supplying (or guessing) their UUID, or spoof
|
|||||||
to. Fixed as part of this task, with regression tests proving
|
to. Fixed as part of this task, with regression tests proving
|
||||||
cross-tenant access now returns 404 (not 403, which would itself leak
|
cross-tenant access now returns 404 (not 403, which would itself leak
|
||||||
that the ID exists under a different tenant) —
|
that the ID exists under a different tenant) —
|
||||||
`api/internal/dashboards/handler_test.go`'s
|
`api/dashboards/handler_test.go`'s
|
||||||
`TestCrossTenant*`/`TestCreateDashboardIgnoresClientSuppliedTenantID`/
|
`TestCrossTenant*`/`TestCreateDashboardIgnoresClientSuppliedTenantID`/
|
||||||
`TestImportIgnoresExportedTenantID`. **This same class of bug should be
|
`TestImportIgnoresExportedTenantID`. **This same class of bug should be
|
||||||
assumed present anywhere else client-supplied identifiers cross a tenant
|
assumed present anywhere else client-supplied identifiers cross a tenant
|
||||||
@@ -200,7 +237,7 @@ altered after the fact" claim actually holds against a privileged
|
|||||||
insider.
|
insider.
|
||||||
|
|
||||||
**Fail-open by design for routine queries.** `queryapi.Handler.logAudit`
|
**Fail-open by design for routine queries.** `queryapi.Handler.logAudit`
|
||||||
(`api/internal/queryapi/handler.go`) logs a write failure and otherwise
|
(`api/queryapi/handler.go`) logs a write failure and otherwise
|
||||||
ignores it — an audit-log outage does not take down the query path. This
|
ignores it — an audit-log outage does not take down the query path. This
|
||||||
is a deliberate availability-over-completeness tradeoff: it means a
|
is a deliberate availability-over-completeness tradeoff: it means a
|
||||||
brief audit outage produces an under-logged (not over-blocked) window.
|
brief audit outage produces an under-logged (not over-blocked) window.
|
||||||
@@ -234,15 +271,21 @@ terms:
|
|||||||
credentials. That's an operational control (credential custody,
|
credentials. That's an operational control (credential custody,
|
||||||
infrastructure access review), out of scope for this system's own
|
infrastructure access review), out of scope for this system's own
|
||||||
code.
|
code.
|
||||||
- **`system.query_log` metadata leakage** (task 2's finding): once
|
- **`system.query_log` metadata leakage — per-tenant users are now
|
||||||
per-tenant ClickHouse users exist, `system.query_log` and related
|
real, but the check itself hasn't run yet.** Was an open verification
|
||||||
`system.*` tables can expose other tenants' query *text* (predicate
|
item because there were no per-tenant ClickHouse users to check
|
||||||
values, field names) even if row-level isolation between databases
|
against; that blocker is gone (`enterprise/internal/tenantprovision`
|
||||||
works perfectly. The design calls for revoking `system.*` access from
|
exists), and `tenantprovision_test.go`'s
|
||||||
every tenant user explicitly, not relying on ClickHouse's default
|
`TestProvisionedUserCannotReadSystemTables` asserts exactly what the
|
||||||
template — this can only be verified once per-tenant users actually
|
design calls for (`system.query_log`/`system.tables` inaccessible,
|
||||||
exist (they don't yet; see the top of this document), so it remains
|
`SHOW DATABASES` not revealing other tenants) — but this environment
|
||||||
an open verification item, not a closed one.
|
never had ClickHouse access to actually run it, so it remains
|
||||||
|
unconfirmed against the pinned version
|
||||||
|
(`clickhouse/clickhouse-server:24.8`) until someone with Docker access
|
||||||
|
runs it (`/docs/phase-4-runbook.md` §8). Also still contingent on the
|
||||||
|
deployment-shape caveat at the top of this document: even once
|
||||||
|
confirmed, this only holds when `enterprise-api` (not plain `api`) is
|
||||||
|
actually serving traffic.
|
||||||
- **No deny-override grants** — `dashboard_permissions` is additive-only
|
- **No deny-override grants** — `dashboard_permissions` is additive-only
|
||||||
by design; a full allow/deny ACL system is unbuilt, future work.
|
by design; a full allow/deny ACL system is unbuilt, future work.
|
||||||
- **No data retention/deletion policy** for a deprovisioned tenant —
|
- **No data retention/deletion policy** for a deprovisioned tenant —
|
||||||
@@ -278,12 +321,16 @@ terms:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Role-based access control on `/query`, `/dashboards` | **Enforced** |
|
| Role-based access control on `/query`, `/dashboards` | **Enforced** |
|
||||||
| `alerting`↔`api` service-identity credential | **Enforced** |
|
| `alerting`↔`api` service-identity credential | **Enforced** |
|
||||||
| Tenant scoping on dashboards (control-plane data) | **Enforced** (fixed this task) |
|
| Tenant scoping on dashboards (control-plane data) | **Enforced** |
|
||||||
| Tenant isolation on log data (`/query` → ClickHouse/Tantivy) | **Not implemented** |
|
| ClickHouse per-tenant provisioning (`tenantprovision`) | **Built, not live-verified** — real integration test exists, not yet run against ClickHouse |
|
||||||
|
| ClickHouse query routing (`chrunner`) | **Built, not live-verified** — and only applies when `enterprise-api` serves traffic, not plain `api` |
|
||||||
|
| `system.*` ClickHouse metadata isolation | **Built, not live-verified** — same caveat as above |
|
||||||
|
| Tantivy/free-text tenant isolation | **Not implemented** — no per-tenant index routing at all |
|
||||||
|
| Deployment actually routing traffic to `enterprise-api` | **Not implemented** — no Helm service, no default wiring |
|
||||||
| Human SSO login (OIDC/SAML) | **Not implemented** |
|
| Human SSO login (OIDC/SAML) | **Not implemented** |
|
||||||
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
|
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
|
||||||
| Query audit logging (routine queries) | **Enforced**, fail-open |
|
| Query audit logging (routine queries) | **Enforced**, fail-open, and now wired to a real writer via `enterprise-api` (`audit.QueryAPILogger`) |
|
||||||
| Audit log tamper detection (hash chain) | **Enforced**, verified live |
|
| Audit log tamper detection (hash chain) | **Enforced**, verified live |
|
||||||
| Audit log tamper prevention (external anchoring) | **Design only** — `FileSink` is a dev stand-in |
|
| 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 |
|
| Mid-provisioning-race handling (evaluator ticks against a not-yet-active tenant) | **Unverified** — see `api/queryapi/tenant_isolation_gap_test.go` |
|
||||||
| Protection against a privileged DB administrator | **Explicit non-goal** |
|
| Protection against a privileged DB administrator | **Explicit non-goal** |
|
||||||
|
|||||||
+112
-25
@@ -5,20 +5,26 @@ boundary. SSO (OIDC/SAML), tenant provisioning, and RBAC. Nothing in
|
|||||||
`/agent`, `/ingest`, `/storage`, `/api`, `/web` core, or `/cli` imports
|
`/agent`, `/ingest`, `/storage`, `/api`, `/web` core, or `/cli` imports
|
||||||
from this module — confirmed by `hack/check-tenant-boundary.sh`, run in
|
from this module — confirmed by `hack/check-tenant-boundary.sh`, run in
|
||||||
CI. `enterprise/` supplies tenant-scoped implementations of core's
|
CI. `enterprise/` supplies tenant-scoped implementations of core's
|
||||||
already-shipped `api/internal/querylang/executor.SQLRunner`/
|
already-shipped `api/querylang/executor.SQLRunner`/
|
||||||
`SearchClient` interfaces rather than core growing tenant awareness —
|
`SearchClient` interfaces rather than core growing tenant awareness —
|
||||||
see `/docs/phase-4-isolation-design.md` for why.
|
see `/docs/phase-4-isolation-design.md` for why.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Tasks 3-5 (module skeleton, SSO library wiring, audit logging, and auth
|
What's built and wired end-to-end. Verification status varies by
|
||||||
wiring in `/api`/`/web`/`/cli`) are built and tested. What's live
|
piece -- `internal/audit` was confirmed against a real Postgres earlier
|
||||||
end-to-end:
|
in this phase's work; everything else below has real integration tests
|
||||||
|
written the same way (skipped unless a live database's connection
|
||||||
|
details are supplied via env var, same pattern throughout this package)
|
||||||
|
but they have **not actually been run against a live database in this
|
||||||
|
environment** -- see `/docs/phase-4-runbook.md`'s verification-status
|
||||||
|
section for exactly what "not yet run" means here and why. Don't read
|
||||||
|
"has a test for this" as "this was confirmed to work."
|
||||||
|
|
||||||
- `internal/session` issues/validates signed (HS256/JWT) tokens for both
|
- `internal/session` issues/validates signed (HS256/JWT) tokens for both
|
||||||
human sessions and `/alerting`'s `RoleService` credential.
|
human sessions and `/alerting`'s `RoleService` credential.
|
||||||
- `internal/authhandler` serves `POST /internal/authorize` (the endpoint
|
- `internal/authhandler` serves `POST /internal/authorize` (the endpoint
|
||||||
`api/internal/authz.HTTPAuthorizer` calls) and `GET /auth/features`
|
`api/authz.HTTPAuthorizer` calls) and `GET /auth/features`
|
||||||
(the runtime-capability check `/web`'s settings page reads).
|
(the runtime-capability check `/web`'s settings page reads).
|
||||||
- `api`'s `/query` and `/dashboards` endpoints enforce RBAC via
|
- `api`'s `/query` and `/dashboards` endpoints enforce RBAC via
|
||||||
`authz.RequireRole`/`RequireRoleOrService`, nil-safe (no-op) when
|
`authz.RequireRole`/`RequireRoleOrService`, nil-safe (no-op) when
|
||||||
@@ -29,8 +35,30 @@ end-to-end:
|
|||||||
- `sentryctl` presents `$SENTRYCTL_TOKEN` as a Bearer credential on every
|
- `sentryctl` presents `$SENTRYCTL_TOKEN` as a Bearer credential on every
|
||||||
request when set.
|
request when set.
|
||||||
- `internal/rbacstore`: full CRUD over `users`/`tenants`/
|
- `internal/rbacstore`: full CRUD over `users`/`tenants`/
|
||||||
`tenant_memberships` (`metadata/migrations/0017-0023`), verified
|
`tenant_memberships`/`data_sources` (`metadata/migrations/0017-0032`).
|
||||||
against a live Postgres.
|
- `internal/tenantprovision`: real `CREATE DATABASE`/`CREATE USER`/
|
||||||
|
`GRANT` against ClickHouse. Its tests assert a tenant A user cannot
|
||||||
|
read tenant B's database by fully-qualified name, and that
|
||||||
|
`system.query_log`/`system.tables`/`SHOW DATABASES` don't leak across
|
||||||
|
tenants either (task 2's finding was that the latter is
|
||||||
|
version-dependent) -- not yet run against a live ClickHouse in this
|
||||||
|
environment, see the note above.
|
||||||
|
- `internal/chrunner`: the tenant-scoped `SQLRunner` -- a per-tenant
|
||||||
|
connection registry that resolves which tenant's ClickHouse connection
|
||||||
|
to use from the authenticated identity in request context, never a
|
||||||
|
parameter. Same adversarial probe, now through the actual production
|
||||||
|
code path (`chrunner.Registry.RunSQL`, not just tenantprovision's raw
|
||||||
|
grants).
|
||||||
|
- `internal/audit.QueryAPILogger`: the real `api/queryapi.AuditLogger`
|
||||||
|
implementation -- wired into `enterprise-api`, no longer `nil`.
|
||||||
|
- `cmd/enterprise-api`: a second binary (alongside `api/cmd/api`,
|
||||||
|
unchanged) importing *both* `api`'s handler packages and the
|
||||||
|
tenant-aware implementations above -- see its own doc comment for why
|
||||||
|
this shape exists (`enterprise → api` is the allowed import direction;
|
||||||
|
`api` can never import `enterprise/`). `-provision-tenant=<id>` is the
|
||||||
|
operator action that provisions ClickHouse and marks a tenant active,
|
||||||
|
same "offline action, not a network endpoint" shape as
|
||||||
|
`enterprise-auth -mint-service-token`.
|
||||||
|
|
||||||
**Deliberately deferred, not half-built** -- named explicitly rather than
|
**Deliberately deferred, not half-built** -- named explicitly rather than
|
||||||
silently left out:
|
silently left out:
|
||||||
@@ -39,37 +67,42 @@ silently left out:
|
|||||||
`internal/saml` do the protocol mechanics; nothing calls them from an
|
`internal/saml` do the protocol mechanics; nothing calls them from an
|
||||||
HTTP handler yet). `-mint-service-token` is the only way to get a
|
HTTP handler yet). `-mint-service-token` is the only way to get a
|
||||||
token today, and it only mints `RoleService` credentials.
|
token today, and it only mints `RoleService` credentials.
|
||||||
- `dashboard_permissions`/`data_sources` CRUD (schema exists,
|
- `dashboard_permissions` CRUD (schema exists,
|
||||||
`metadata/migrations/0024-0026`; no caller reads per-resource grants
|
`metadata/migrations/0024`; no caller reads per-resource grants
|
||||||
yet -- `dashboards`' handler enforces tenant-baseline role only, not
|
yet -- `dashboards`' handler enforces tenant-baseline role only, not
|
||||||
the matrix's "(own/granted)" qualifier).
|
the matrix's "(own/granted)" qualifier).
|
||||||
- `internal/tenantprovision` (ClickHouse DB/user/grant + Tantivy index
|
- `internal/searchclient` (the Tantivy-side sibling of `chrunner`) --
|
||||||
provisioning) and the tenant-scoped `internal/chrunner`/
|
`enterprise-api` shares the single, un-tenant-scoped Tantivy index
|
||||||
`internal/searchclient` `SQLRunner`/`SearchClient` implementations --
|
every deployment does today (`api/searchclient.Dial`, unchanged). See
|
||||||
task 2's isolation model, not yet built against real per-tenant
|
`/docs/security/threat-model.md`.
|
||||||
connections.
|
- Any deployment-topology mechanism that actually routes traffic to
|
||||||
- Wiring `internal/audit` into `api`'s `queryapi.AuditLogger` extension
|
`enterprise-api` instead of `api` -- both binaries exist,
|
||||||
point (built in core since task 4, still passed as `nil`).
|
`docker-compose.yml` includes `enterprise-api` available but not
|
||||||
|
wired into `web`'s default base URL, and the Helm chart has no
|
||||||
|
service for it at all yet.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features endpoints, -mint-service-token
|
cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features endpoints, -mint-service-token
|
||||||
|
cmd/enterprise-api/ multi-tenant-aware alternative to api/cmd/api -- see its own doc comment
|
||||||
internal/tenant/ the ID type -- see its package doc comment before touching it
|
internal/tenant/ the ID type -- see its package doc comment before touching it
|
||||||
internal/oidc/ coreos/go-oidc wiring: discovery, login redirect, code exchange + ID token verification
|
internal/oidc/ coreos/go-oidc wiring: discovery, login redirect, code exchange + ID token verification
|
||||||
internal/saml/ crewjam/saml wiring: SP setup, login redirect, response parsing/validation
|
internal/saml/ crewjam/saml wiring: SP setup, login redirect, response parsing/validation
|
||||||
internal/session/ issues/validates signed session + RoleService tokens
|
internal/session/ issues/validates signed session + RoleService tokens
|
||||||
internal/authhandler/ POST /internal/authorize, GET /auth/features
|
internal/authhandler/ POST /internal/authorize, GET /auth/features
|
||||||
internal/rbacstore/ users/tenants/tenant_memberships CRUD (pgx against sentry_metadata)
|
internal/rbacstore/ users/tenants/tenant_memberships/data_sources CRUD (pgx against sentry_metadata)
|
||||||
internal/audit/ append-only, hash-chained query audit log -- see its own package
|
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
|
||||||
doc comment and /docs/phase-4-isolation-design.md's audit section
|
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
|
||||||
internal/config/ env-var config, same convention as every other Go service here
|
internal/audit/ append-only, hash-chained query audit log, plus the
|
||||||
|
api/queryapi.AuditLogger adapter (queryapi_adapter.go)
|
||||||
|
internal/apiconfig/ enterprise-api's own env-var config
|
||||||
|
internal/config/ enterprise-auth's env-var config
|
||||||
```
|
```
|
||||||
|
|
||||||
Future additions: `internal/tenantprovision`, `internal/chrunner`/
|
Future additions: `internal/searchclient`, the OIDC/SAML login/callback
|
||||||
`internal/searchclient` (tenant-scoped `SQLRunner`/`SearchClient`
|
HTTP handlers, `dashboard_permissions` CRUD, and real deployment-topology
|
||||||
implementations), the OIDC/SAML login/callback HTTP handlers, and
|
wiring for `enterprise-api` -- see "Status" above.
|
||||||
`dashboard_permissions`/`data_sources` CRUD -- see "Status" above.
|
|
||||||
|
|
||||||
## Why OIDC and SAML aren't hand-rolled
|
## Why OIDC and SAML aren't hand-rolled
|
||||||
|
|
||||||
@@ -113,6 +146,23 @@ docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
|
|||||||
golang:1.25-alpine go test ./internal/rbacstore/... -v
|
golang:1.25-alpine go test ./internal/rbacstore/... -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`internal/tenantprovision` and `internal/chrunner` need a real
|
||||||
|
ClickHouse instead (they mount the repo root, not just `enterprise/`,
|
||||||
|
since `internal/chrunner` imports `api/authz`/`api/querylang/executor`
|
||||||
|
via `go.mod`'s `replace` directives to `../api`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
|
||||||
|
-e TENANTPROVISION_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
|
||||||
|
-e TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
|
||||||
|
golang:1.25-alpine go test ./internal/tenantprovision/... -v
|
||||||
|
|
||||||
|
docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \
|
||||||
|
-e CHRUNNER_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
|
||||||
|
-e CHRUNNER_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
|
||||||
|
golang:1.25-alpine go test ./internal/chrunner/... -v
|
||||||
|
```
|
||||||
|
|
||||||
## Turning on auth enforcement for manual testing
|
## Turning on auth enforcement for manual testing
|
||||||
|
|
||||||
Off by default (see "Status" above -- there's no login flow to issue a
|
Off by default (see "Status" above -- there's no login flow to issue a
|
||||||
@@ -129,7 +179,25 @@ TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=alerting)
|
|||||||
docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise/, not the repo root
|
docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise/, not the repo root
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Provisioning a tenant and running `enterprise-api`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose build enterprise-api # context is the repo root, not enterprise/ -- see cmd/enterprise-api/Dockerfile
|
||||||
|
docker compose run --rm enterprise-api -provision-tenant=acme -display-name="Acme Corp"
|
||||||
|
docker compose up -d enterprise-api
|
||||||
|
curl -s http://localhost:8083/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
`-provision-tenant` creates the tenant/data_source rows in rbacstore if
|
||||||
|
they don't exist, provisions ClickHouse, persists the credentials, and
|
||||||
|
marks the tenant active -- refuses to run twice for the same tenant
|
||||||
|
(re-provisioning would either rotate a live credential or silently fail
|
||||||
|
to, see `tenantprovision.ProvisionClickHouse`'s doc comment). `web`
|
||||||
|
still points at plain `api` by default (`VITE_API_BASE_URL`) --
|
||||||
|
pointing it at `enterprise-api` instead is a manual `docker-compose.yml`
|
||||||
|
edit today, not a supported flag.
|
||||||
|
|
||||||
|
## Environment variables (`enterprise-auth`)
|
||||||
|
|
||||||
| Var | Default |
|
| Var | Default |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -146,3 +214,22 @@ docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise
|
|||||||
| `SAML_ACS_URL` | (empty) |
|
| `SAML_ACS_URL` | (empty) |
|
||||||
| `SAML_IDP_METADATA_URL` | (empty — presence only feeds `GET /auth/features`; not yet fetched/parsed) |
|
| `SAML_IDP_METADATA_URL` | (empty — presence only feeds `GET /auth/features`; not yet fetched/parsed) |
|
||||||
| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes |
|
| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes |
|
||||||
|
|
||||||
|
## Environment variables (`enterprise-api`)
|
||||||
|
|
||||||
|
| Var | Default |
|
||||||
|
|---|---|
|
||||||
|
| `HTTP_LISTEN_ADDR` | `:8083` |
|
||||||
|
| `CLICKHOUSE_ADDR` | `localhost:9000` |
|
||||||
|
| `CLICKHOUSE_ADMIN_USERNAME` | `default` |
|
||||||
|
| `CLICKHOUSE_ADMIN_PASSWORD` | (empty) |
|
||||||
|
| `SEARCH_GRPC_ADDR` | `localhost:50052` |
|
||||||
|
| `POSTGRES_ADDR` | `localhost:5432` |
|
||||||
|
| `POSTGRES_DATABASE` | `sentry_metadata` |
|
||||||
|
| `POSTGRES_USERNAME` | `sentry` |
|
||||||
|
| `POSTGRES_PASSWORD` | (empty) |
|
||||||
|
| `AUDIT_WRITER_USERNAME` | `audit_writer` |
|
||||||
|
| `AUDIT_WRITER_PASSWORD` | (empty) |
|
||||||
|
| `ENTERPRISE_AUTH_URL` | (empty — RBAC becomes a no-op, but `chrunner.Registry.RunSQL` still refuses every query with no resolved tenant identity, so leaving this unset does not mean "open access," it means "every query fails") |
|
||||||
|
| `CORS_ALLOWED_ORIGIN` | `*` |
|
||||||
|
| `QUERY_TIMEOUT_SECONDS` | `30` |
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Same shape as every other Go service's Dockerfile in this repo --
|
||||||
|
# context must be the repo root (needs both enterprise/ and proto/, like
|
||||||
|
# api/Dockerfile does for api/ + proto/), not enterprise/ alone.
|
||||||
|
# docker build -f enterprise/cmd/enterprise-api/Dockerfile -t sentry-enterprise-api .
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
WORKDIR /src/enterprise
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/enterprise-api ./cmd/enterprise-api
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=builder /out/enterprise-api /enterprise-api
|
||||||
|
ENTRYPOINT ["/enterprise-api"]
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// Command enterprise-api is the multi-tenant-aware alternative to
|
||||||
|
// api/cmd/api -- same POST /query and /dashboards surface (it reuses
|
||||||
|
// api/queryapi and api/dashboards's actual Handler types unchanged), but
|
||||||
|
// backed by a per-tenant ClickHouse connection registry
|
||||||
|
// (enterprise/internal/chrunner) instead of the single shared connection
|
||||||
|
// api/cmd/api opens, and a real audit logger
|
||||||
|
// (enterprise/internal/audit.QueryAPILogger) instead of the nil api's
|
||||||
|
// binary has carried since Phase 4 task 4.
|
||||||
|
//
|
||||||
|
// Why a second binary, not a flag on api/cmd/api: api is AGPL core and
|
||||||
|
// must never import enterprise/ (hack/check-tenant-boundary.sh enforces
|
||||||
|
// this) -- there is no way for api's own binary to construct an
|
||||||
|
// enterprise-supplied chrunner.Registry or audit.Store without that
|
||||||
|
// import. enterprise/ importing api/ is the allowed direction, so this
|
||||||
|
// binary lives here instead, wiring core's handler types together with
|
||||||
|
// enterprise's tenant-aware implementations. A single-tenant deployment
|
||||||
|
// keeps running plain api/cmd/api, unchanged; a real multi-tenant
|
||||||
|
// deployment runs this one instead.
|
||||||
|
//
|
||||||
|
// Not built yet: per-tenant Tantivy routing (search stays the single
|
||||||
|
// shared api/searchclient.Dial connection every tenant shares --
|
||||||
|
// see /docs/security/threat-model.md), and the actual K8s/Helm wiring
|
||||||
|
// to run this binary in place of api's (docker-compose.yml adds it
|
||||||
|
// available, not defaulted into the traffic path, same shape as
|
||||||
|
// enterprise-auth's own addition in Phase 4 task 5).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
chdriver "github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/authz"
|
||||||
|
"github.com/sentry/sentry/api/dashboards"
|
||||||
|
"github.com/sentry/sentry/api/httpserver"
|
||||||
|
"github.com/sentry/sentry/api/queryapi"
|
||||||
|
"github.com/sentry/sentry/api/searchclient"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/apiconfig"
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/audit"
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/chrunner"
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/tenantprovision"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
|
cfg, err := apiconfig.Load()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("loading config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||||
|
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
|
||||||
|
}
|
||||||
|
|
||||||
|
provisionTenant := flag.String("provision-tenant", "", "provision ClickHouse for the named tenant id (creating it in rbacstore if needed) and exit")
|
||||||
|
provisionDisplayName := flag.String("display-name", "", "display name for -provision-tenant, if the tenant doesn't already exist in rbacstore")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
|
||||||
|
pgPool, err := pgxpool.New(ctx, pgDSN)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("opening postgres pool", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pgPool.Close()
|
||||||
|
if err := pgPool.Ping(ctx); err != nil {
|
||||||
|
logger.Error("pinging postgres", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
rbac := rbacstore.NewStore(pgPool)
|
||||||
|
|
||||||
|
if *provisionTenant != "" {
|
||||||
|
os.Exit(runProvisionTenant(ctx, logger, cfg, rbac, *provisionTenant, *provisionDisplayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
adminConn, err := chdriver.Open(&chdriver.Options{
|
||||||
|
Addr: []string{cfg.ClickHouseAddr},
|
||||||
|
Auth: chdriver.Auth{Database: "default", Username: cfg.ClickHouseAdmin.Username, Password: cfg.ClickHouseAdmin.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("opening clickhouse admin connection", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer adminConn.Close()
|
||||||
|
|
||||||
|
sources, err := rbac.ListProvisionedDataSources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("listing provisioned data sources", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
chrunnerSources := make([]chrunner.DataSource, 0, len(sources))
|
||||||
|
for _, s := range sources {
|
||||||
|
if s.ClickHouseUsername == nil || s.ClickHousePassword == nil {
|
||||||
|
continue // ListProvisionedDataSources already filters these out; defensive only.
|
||||||
|
}
|
||||||
|
chrunnerSources = append(chrunnerSources, chrunner.DataSource{
|
||||||
|
TenantID: s.TenantID, Database: s.ClickHouseDatabaseName,
|
||||||
|
Username: *s.ClickHouseUsername, Password: *s.ClickHousePassword,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.Info("loaded tenant data sources", "count", len(chrunnerSources))
|
||||||
|
|
||||||
|
registry, err := chrunner.New(ctx, cfg.ClickHouseAddr, chrunnerSources)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("building tenant connection registry", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer registry.Close()
|
||||||
|
|
||||||
|
search, err := searchclient.Dial(cfg.SearchGRPCAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("dialing search service", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer search.Close()
|
||||||
|
|
||||||
|
var authorizer authz.Authorizer
|
||||||
|
if cfg.EnterpriseAuthURL != "" {
|
||||||
|
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
|
||||||
|
} else {
|
||||||
|
logger.Warn("ENTERPRISE_AUTH_URL is not set -- RBAC enforcement is a no-op, but tenant query routing still requires a resolved identity, so every /query request will be refused (see chrunner.Registry.RunSQL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
auditWriterDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.AuditWriter.Username, cfg.AuditWriter.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
|
||||||
|
auditPool, err := pgxpool.New(ctx, auditWriterDSN)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("opening audit_writer postgres pool", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer auditPool.Close()
|
||||||
|
if err := auditPool.Ping(ctx); err != nil {
|
||||||
|
logger.Error("pinging audit_writer postgres pool", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
auditLogger := audit.NewQueryAPILogger(audit.NewStore(auditPool), audit.SourceAPI)
|
||||||
|
|
||||||
|
queryHandler := queryapi.NewHandler(logger, registry, search, cfg.QueryTimeout, auditLogger, authorizer)
|
||||||
|
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
queryHandler.RegisterRoutes(mux)
|
||||||
|
dashboardsHandler.RegisterRoutes(mux)
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.HTTPListenAddr,
|
||||||
|
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("enterprise-api listening", "addr", cfg.HTTPListenAddr)
|
||||||
|
errCh <- srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("graceful shutdown failed", "error", err)
|
||||||
|
}
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Error("server exited with error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runProvisionTenant is the operator action that actually closes
|
||||||
|
// /docs/phase-4-isolation-design.md's ordered provisioning gate: ensure
|
||||||
|
// the tenant row exists, ensure a data_sources row exists, provision
|
||||||
|
// ClickHouse (CREATE USER -> GRANT), persist the returned credentials,
|
||||||
|
// and only then mark the tenant active. Same "offline operator action,
|
||||||
|
// not a network-reachable endpoint" shape as enterprise-auth's
|
||||||
|
// -mint-service-token.
|
||||||
|
func runProvisionTenant(ctx context.Context, logger *slog.Logger, cfg apiconfig.Config, rbac *rbacstore.Store, tenantID, displayName string) int {
|
||||||
|
adminConn, err := chdriver.Open(&chdriver.Options{
|
||||||
|
Addr: []string{cfg.ClickHouseAddr},
|
||||||
|
Auth: chdriver.Auth{Database: "default", Username: cfg.ClickHouseAdmin.Username, Password: cfg.ClickHouseAdmin.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("opening clickhouse admin connection", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer adminConn.Close()
|
||||||
|
|
||||||
|
tenant, err := rbac.GetTenant(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if err != rbacstore.ErrNotFound {
|
||||||
|
logger.Error("getting tenant", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
name := displayName
|
||||||
|
if name == "" {
|
||||||
|
name = tenantID
|
||||||
|
}
|
||||||
|
tenant, err = rbac.CreateTenant(ctx, tenantID, name)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("creating tenant", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
logger.Info("created tenant row", "tenant_id", tenantID)
|
||||||
|
}
|
||||||
|
if tenant.Status == "active" {
|
||||||
|
logger.Error("tenant is already active -- refusing to re-provision (would rotate a live credential)", "tenant_id", tenantID)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSource, err := rbac.GetDataSourceForTenant(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if err != rbacstore.ErrNotFound {
|
||||||
|
logger.Error("getting data source", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
dataSource, err = rbac.CreateDataSource(ctx, tenantID, "default", tenantID, "/var/lib/sentry-search/tenants/"+tenantID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("creating data source", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dataSource.ClickHouseUsername != nil {
|
||||||
|
logger.Error("data source already has ClickHouse credentials -- refusing to re-provision", "tenant_id", tenantID)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := tenantprovision.New(adminConn).ProvisionClickHouse(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("provisioning clickhouse", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := rbac.SetDataSourceClickHouseCredentials(ctx, dataSource.ID, creds.Username, creds.Password); err != nil {
|
||||||
|
logger.Error("persisting clickhouse credentials", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := rbac.SetTenantStatus(ctx, tenantID, "active"); err != nil {
|
||||||
|
logger.Error("activating tenant", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("tenant provisioned and active", "tenant_id", tenantID, "clickhouse_database", tenantID, "clickhouse_username", creds.Username)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHealthcheck(listenAddr string) int {
|
||||||
|
addr := listenAddr
|
||||||
|
if strings.HasPrefix(addr, ":") {
|
||||||
|
addr = "localhost" + addr
|
||||||
|
}
|
||||||
|
client := http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get("http://" + addr + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// /docs/phase-4-isolation-design.md and /docs/phase-4-rbac-design.md.
|
// /docs/phase-4-isolation-design.md and /docs/phase-4-rbac-design.md.
|
||||||
//
|
//
|
||||||
// Phase 4 task 5 adds session issuance/validation (internal/session) and
|
// Phase 4 task 5 adds session issuance/validation (internal/session) and
|
||||||
// the POST /internal/authorize endpoint api/internal/authz.HTTPAuthorizer
|
// the POST /internal/authorize endpoint api/authz.HTTPAuthorizer
|
||||||
// calls -- the piece that actually turns on RBAC enforcement in /api.
|
// calls -- the piece that actually turns on RBAC enforcement in /api.
|
||||||
// Still deliberately missing: the OIDC/SAML login/callback HTTP handlers
|
// Still deliberately missing: the OIDC/SAML login/callback HTTP handlers
|
||||||
// that would issue a *human* session after a real IdP round trip, and
|
// that would issue a *human* session after a real IdP round trip, and
|
||||||
|
|||||||
+38
-3
@@ -2,7 +2,24 @@ module github.com/sentry/sentry/enterprise
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
// enterprise/ importing api/ (core) is the allowed direction of the
|
||||||
|
// module boundary hack/check-tenant-boundary.sh enforces -- see
|
||||||
|
// enterprise/internal/chrunner's doc comment: it implements api's
|
||||||
|
// executor.SQLRunner interface, which structurally requires importing
|
||||||
|
// the package that defines it.
|
||||||
|
replace github.com/sentry/sentry/api => ../api
|
||||||
|
|
||||||
|
// api's own go.mod replace directive for proto/ is module-local and
|
||||||
|
// doesn't propagate here -- enterprise/ needs its own, or `go build`
|
||||||
|
// tries to fetch github.com/sentry/sentry/proto from a real (nonexistent)
|
||||||
|
// remote, since api/searchclient (now transitively imported) depends on
|
||||||
|
// the generated search gRPC stubs.
|
||||||
|
replace github.com/sentry/sentry/proto => ../proto
|
||||||
|
|
||||||
|
require github.com/sentry/sentry/api v0.0.0-00010101000000-000000000000
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.48.0
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
github.com/crewjam/saml v0.5.1
|
github.com/crewjam/saml v0.5.1
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
@@ -12,14 +29,32 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.74.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||||
github.com/beevik/etree v1.5.0 // indirect
|
github.com/beevik/etree v1.5.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jonboulle/clockwork v0.2.2 // indirect
|
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.19.1 // indirect
|
||||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||||
|
github.com/paulmach/orb v0.13.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||||
github.com/russellhaering/goxmldsig v1.4.0 // indirect
|
github.com/russellhaering/goxmldsig v1.4.0 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
|
golang.org/x/net v0.57.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
|
google.golang.org/grpc v1.83.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+64
-9
@@ -1,6 +1,14 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE=
|
||||||
|
github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM=
|
||||||
|
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
|
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
|
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
|
||||||
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
@@ -9,12 +17,22 @@ github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhy
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -27,6 +45,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
|||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||||
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||||
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
@@ -35,29 +55,64 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||||
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||||
|
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
|
||||||
|
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
|
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
|
||||||
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||||
|
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package apiconfig loads enterprise-api's configuration from
|
||||||
|
// environment variables -- same convention as every other Go service in
|
||||||
|
// this repo. Named apiconfig, not config, to avoid colliding with the
|
||||||
|
// already-existing enterprise/internal/config (enterprise-auth's own,
|
||||||
|
// differently-shaped config) within the same module.
|
||||||
|
package apiconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPListenAddr string
|
||||||
|
// ClickHouseAddr is the shared physical ClickHouse server's native
|
||||||
|
// address -- every tenant's connection (chrunner.Registry) and the
|
||||||
|
// admin connection (tenantprovision) both dial this same address,
|
||||||
|
// just with different credentials. Tenants sharing one physical
|
||||||
|
// server is today's model; per-tenant dedicated cluster nodes is
|
||||||
|
// named as later, non-schema-changing work in
|
||||||
|
// /docs/phase-4-isolation-design.md.
|
||||||
|
ClickHouseAddr string
|
||||||
|
// ClickHouseAdmin is the access_management-enabled credential
|
||||||
|
// tenantprovision uses for CREATE DATABASE/USER/GRANT -- the same
|
||||||
|
// credential api's plain (non-enterprise) binary uses as its one
|
||||||
|
// shared connection today (docker-compose.yml's CLICKHOUSE_PASSWORD).
|
||||||
|
// Never used to run a tenant's actual queries.
|
||||||
|
ClickHouseAdmin ClickHouseAdminConfig
|
||||||
|
Postgres PostgresConfig
|
||||||
|
AuditWriter AuditWriterConfig
|
||||||
|
SearchGRPCAddr string
|
||||||
|
QueryTimeout time.Duration
|
||||||
|
CORSAllowedOrigin string
|
||||||
|
// EnterpriseAuthURL, like api's own config, is optional -- see that
|
||||||
|
// package's doc comment on the nil-authorizer no-op default. In
|
||||||
|
// practice a real enterprise-api deployment always sets this (there
|
||||||
|
// is no reason to run this binary instead of plain api without RBAC
|
||||||
|
// enforcement on), but nothing here hard-requires it, for the same
|
||||||
|
// "never break a simpler deployment shape" reasoning used
|
||||||
|
// throughout this codebase.
|
||||||
|
EnterpriseAuthURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClickHouseAdminConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostgresConfig struct {
|
||||||
|
Addr string
|
||||||
|
Database string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditWriterConfig is the separate, narrowly-granted credential
|
||||||
|
// enterprise/internal/audit.Store requires -- see that package's doc
|
||||||
|
// comment on why it must never share api's/dashboards' pool.
|
||||||
|
type AuditWriterConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
cfg := Config{
|
||||||
|
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8083"),
|
||||||
|
ClickHouseAddr: getenv("CLICKHOUSE_ADDR", "localhost:9000"),
|
||||||
|
ClickHouseAdmin: ClickHouseAdminConfig{
|
||||||
|
Username: getenv("CLICKHOUSE_ADMIN_USERNAME", "default"),
|
||||||
|
Password: getenv("CLICKHOUSE_ADMIN_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
Postgres: PostgresConfig{
|
||||||
|
Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
|
||||||
|
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"),
|
||||||
|
Username: getenv("POSTGRES_USERNAME", "sentry"),
|
||||||
|
Password: getenv("POSTGRES_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
AuditWriter: AuditWriterConfig{
|
||||||
|
Username: getenv("AUDIT_WRITER_USERNAME", "audit_writer"),
|
||||||
|
Password: getenv("AUDIT_WRITER_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
SearchGRPCAddr: getenv("SEARCH_GRPC_ADDR", "localhost:50052"),
|
||||||
|
CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"),
|
||||||
|
EnterpriseAuthURL: getenv("ENTERPRISE_AUTH_URL", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutSec, err := strconv.Atoi(getenv("QUERY_TIMEOUT_SECONDS", "30"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("QUERY_TIMEOUT_SECONDS: %w", err)
|
||||||
|
}
|
||||||
|
cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -18,8 +18,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/authz"
|
||||||
|
"github.com/sentry/sentry/api/queryapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testPool(t *testing.T, user, password string) *pgxpool.Pool {
|
func testPool(t *testing.T, user, password string) *pgxpool.Pool {
|
||||||
@@ -92,6 +96,50 @@ func TestAppendAndVerifyChainRealPostgres(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryAPILoggerWritesAttributedToContextIdentity proves the
|
||||||
|
// adapter queryapi.Handler actually calls in production (via
|
||||||
|
// enterprise-api's wiring) reads tenant/user from context, not from any
|
||||||
|
// field on QueryAuditEntry -- matching that type's own doc comment.
|
||||||
|
func TestQueryAPILoggerWritesAttributedToContextIdentity(t *testing.T) {
|
||||||
|
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
|
||||||
|
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
|
||||||
|
cleanupAuditLog(t, adminPool)
|
||||||
|
defer cleanupAuditLog(t, adminPool)
|
||||||
|
|
||||||
|
logger := NewQueryAPILogger(NewStore(writerPool), SourceAPI)
|
||||||
|
ctx := authz.WithIdentity(context.Background(), authz.Identity{TenantID: "acme", UserID: "11111111-1111-1111-1111-111111111111", Role: authz.RoleViewer})
|
||||||
|
|
||||||
|
err := logger.LogQuery(ctx, queryapi.QueryAuditEntry{
|
||||||
|
Query: "stats count", Language: "spl", RowCount: 3, Duration: 42 * time.Millisecond, Success: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LogQuery: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantID, userID, queryText string
|
||||||
|
row := adminPool.QueryRow(context.Background(),
|
||||||
|
`SELECT tenant_id, user_id, query_text FROM audit_log ORDER BY id DESC LIMIT 1`)
|
||||||
|
if err := row.Scan(&tenantID, &userID, &queryText); err != nil {
|
||||||
|
t.Fatalf("reading back the written row: %v", err)
|
||||||
|
}
|
||||||
|
if tenantID != "acme" || userID != "11111111-1111-1111-1111-111111111111" || queryText != "stats count" {
|
||||||
|
t.Fatalf("got tenant_id=%q user_id=%q query_text=%q, want acme/11111111-.../\"stats count\"", tenantID, userID, queryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryAPILoggerRefusesWithoutIdentity(t *testing.T) {
|
||||||
|
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
|
||||||
|
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
|
||||||
|
cleanupAuditLog(t, adminPool)
|
||||||
|
defer cleanupAuditLog(t, adminPool)
|
||||||
|
|
||||||
|
logger := NewQueryAPILogger(NewStore(writerPool), SourceAPI)
|
||||||
|
err := logger.LogQuery(context.Background(), queryapi.QueryAuditEntry{Query: "stats count", Success: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected LogQuery to refuse writing an entry with no tenant identity in context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVerifyChainDetectsTampering proves the chain actually catches an
|
// TestVerifyChainDetectsTampering proves the chain actually catches an
|
||||||
// in-place row modification -- not just that VerifyChain runs without
|
// in-place row modification -- not just that VerifyChain runs without
|
||||||
// erroring on untampered data, which a bug returning OK unconditionally
|
// erroring on untampered data, which a bug returning OK unconditionally
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Adapts *Store to api/queryapi.AuditLogger -- the interface core
|
||||||
|
// defines and has carried as a nil-by-default field
|
||||||
|
// (api/queryapi.Handler.audit) since Phase 4 task 4, waiting on exactly
|
||||||
|
// this: a real implementation, wired in by enterprise/cmd/enterprise-api
|
||||||
|
// (the one binary allowed to import both packages -- see chrunner's doc
|
||||||
|
// comment on the enterprise->api import direction).
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/authz"
|
||||||
|
"github.com/sentry/sentry/api/queryapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryAPILogger implements queryapi.AuditLogger by translating its
|
||||||
|
// tenant-agnostic QueryAuditEntry into this package's Entry, reading
|
||||||
|
// tenant/user identity from ctx -- exactly the shape
|
||||||
|
// queryapi.AuditLogger's doc comment describes: "an enterprise-side
|
||||||
|
// implementation reads identity from ctx rather than this interface
|
||||||
|
// growing tenant-awareness."
|
||||||
|
type QueryAPILogger struct {
|
||||||
|
store *Store
|
||||||
|
source Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQueryAPILogger wraps store for use as a specific Source -- api's
|
||||||
|
// queryapi.Handler and /alerting's evaluations go through different
|
||||||
|
// enterprise-api-fronted paths today (SourceAPI is the only one
|
||||||
|
// actually wired to a real HTTP handler; SourceAlerting is named for
|
||||||
|
// when alerting's queries get audited the same way, not yet built).
|
||||||
|
func NewQueryAPILogger(store *Store, source Source) *QueryAPILogger {
|
||||||
|
return &QueryAPILogger{store: store, source: source}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *QueryAPILogger) LogQuery(ctx context.Context, entry queryapi.QueryAuditEntry) error {
|
||||||
|
identity, ok := authz.IdentityFromContext(ctx)
|
||||||
|
if !ok || identity.TenantID == "" {
|
||||||
|
// Fail open at the queryapi.Handler call site already covers
|
||||||
|
// "don't take down the query path" -- this specific error tells
|
||||||
|
// that fail-open path *why* the write didn't happen, distinct
|
||||||
|
// from a real audit-storage failure, since chrunner.RunSQL
|
||||||
|
// would already have refused the query itself in this case (see
|
||||||
|
// that package's RunSQL) -- this branch mostly protects against
|
||||||
|
// a future caller that skips chrunner's own check.
|
||||||
|
return fmt.Errorf("audit: no tenant identity in context, refusing to write an unattributable audit entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := StatusSuccess
|
||||||
|
var errMsg *string
|
||||||
|
if !entry.Success {
|
||||||
|
status = StatusError
|
||||||
|
errMsg = &entry.Error
|
||||||
|
}
|
||||||
|
var userID *string
|
||||||
|
if identity.UserID != "" {
|
||||||
|
userID = &identity.UserID
|
||||||
|
}
|
||||||
|
queryText := entry.Query
|
||||||
|
rowCount := entry.RowCount
|
||||||
|
durationMS := int(entry.Duration.Milliseconds())
|
||||||
|
|
||||||
|
_, err := l.store.Append(ctx, Entry{
|
||||||
|
TenantID: identity.TenantID,
|
||||||
|
UserID: userID,
|
||||||
|
Source: l.source,
|
||||||
|
EventType: EventQuery,
|
||||||
|
QueryText: &queryText,
|
||||||
|
RowCount: &rowCount,
|
||||||
|
DurationMS: &durationMS,
|
||||||
|
Status: status,
|
||||||
|
ErrorMessage: errMsg,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package authhandler implements enterprise-auth's POST /internal/authorize
|
// Package authhandler implements enterprise-auth's POST /internal/authorize
|
||||||
// endpoint -- the HTTP side of the "network boundary, not import boundary"
|
// endpoint -- the HTTP side of the "network boundary, not import boundary"
|
||||||
// pattern api/internal/authz.HTTPAuthorizer calls into (see that package's
|
// pattern api/authz.HTTPAuthorizer calls into (see that package's
|
||||||
// doc comment). It resolves a caller's credentials (session cookie or
|
// doc comment). It resolves a caller's credentials (session cookie or
|
||||||
// service-token Bearer header) to an identity, using session.Manager for
|
// service-token Bearer header) to an identity, using session.Manager for
|
||||||
// both -- a human session and /alerting's service token are both just
|
// both -- a human session and /alerting's service token are both just
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"github.com/sentry/sentry/enterprise/internal/session"
|
"github.com/sentry/sentry/enterprise/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionCookieName matches the name api/internal/authz.HTTPAuthorizer's
|
// SessionCookieName matches the name api/authz.HTTPAuthorizer's
|
||||||
// tests and doc comments already assume ("sentry_session").
|
// tests and doc comments already assume ("sentry_session").
|
||||||
const SessionCookieName = "sentry_session"
|
const SessionCookieName = "sentry_session"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Package chrunner is the tenant-scoped implementation of api's
|
||||||
|
// querylang/executor.SQLRunner interface -- the piece
|
||||||
|
// /docs/security/threat-model.md's headline finding says was missing:
|
||||||
|
// until this package, api/cmd/api/main.go opened exactly one shared
|
||||||
|
// ClickHouse connection for every tenant, no matter how many
|
||||||
|
// tenant_memberships/Tenant CRs existed. This package requires
|
||||||
|
// importing api/querylang/executor and api/authz directly (see
|
||||||
|
// enterprise/go.mod's replace directive) -- implementing
|
||||||
|
// executor.SQLRunner structurally requires it (its RunSQL method
|
||||||
|
// returns *executor.Result, a type only that package defines), and
|
||||||
|
// that's the allowed import direction: enterprise -> api, never the
|
||||||
|
// reverse (hack/check-tenant-boundary.sh enforces that direction only).
|
||||||
|
//
|
||||||
|
// Design, per /docs/phase-4-isolation-design.md's ClickHouse section:
|
||||||
|
// Registry holds one fully separate *executor.ChRunner (and the
|
||||||
|
// driver.Conn under it) per tenant, built once at construction from an
|
||||||
|
// immutable map -- never a shared pool with session-level `USE`, which
|
||||||
|
// is a classic concurrency bug (a connection recycled between tenants
|
||||||
|
// mid-flight can interleave one tenant's session state into another's
|
||||||
|
// query). RunSQL resolves which tenant's runner to use from the
|
||||||
|
// request's authz.Identity (attached to ctx by
|
||||||
|
// api/authz.RequireRole/RequireRoleOrService), never from any
|
||||||
|
// caller-suppliable parameter -- there is no code path in this package
|
||||||
|
// that accepts a tenant ID as an argument to a query-executing method.
|
||||||
|
package chrunner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
"github.com/sentry/sentry/api/authz"
|
||||||
|
"github.com/sentry/sentry/api/querylang/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DataSource is the minimal shape Registry needs to open one tenant's
|
||||||
|
// connection -- deliberately not enterprise/internal/rbacstore.DataSource
|
||||||
|
// itself, so this package doesn't need to import rbacstore just to
|
||||||
|
// describe "an address and a credential." Callers (enterprise-api's
|
||||||
|
// main.go) adapt rbacstore rows into this.
|
||||||
|
type DataSource struct {
|
||||||
|
TenantID string
|
||||||
|
Database string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry implements executor.SQLRunner by routing each call to the
|
||||||
|
// caller's tenant-specific connection. Immutable after New returns --
|
||||||
|
// see this file's doc comment on why that's load-bearing, not just a
|
||||||
|
// style choice.
|
||||||
|
type Registry struct {
|
||||||
|
runners map[string]*executor.ChRunner
|
||||||
|
closers []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens one real ClickHouse connection per DataSource (same native
|
||||||
|
// address for all of them -- tenants sharing a physical ClickHouse
|
||||||
|
// server today, per-tenant *pinning* to dedicated cluster nodes is
|
||||||
|
// named as later, non-schema-changing work in
|
||||||
|
// /docs/phase-4-isolation-design.md, not something this constructor
|
||||||
|
// does). Fails closed: if any one tenant's connection can't be opened
|
||||||
|
// or doesn't ping successfully, the whole Registry fails to construct
|
||||||
|
// rather than silently running with a partial tenant set -- a tenant
|
||||||
|
// missing from the map is a clear, loud "unknown tenant" error at query
|
||||||
|
// time (see RunSQL), not a connection nobody noticed never came up.
|
||||||
|
func New(ctx context.Context, addr string, sources []DataSource) (*Registry, error) {
|
||||||
|
reg := &Registry{runners: make(map[string]*executor.ChRunner, len(sources))}
|
||||||
|
for _, src := range sources {
|
||||||
|
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
|
Addr: []string{addr},
|
||||||
|
Auth: clickhouse.Auth{
|
||||||
|
Database: src.Database,
|
||||||
|
Username: src.Username,
|
||||||
|
Password: src.Password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
reg.Close()
|
||||||
|
return nil, fmt.Errorf("chrunner: opening connection for tenant %q: %w", src.TenantID, err)
|
||||||
|
}
|
||||||
|
if err := conn.Ping(ctx); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
reg.Close()
|
||||||
|
return nil, fmt.Errorf("chrunner: pinging connection for tenant %q: %w", src.TenantID, err)
|
||||||
|
}
|
||||||
|
reg.runners[src.TenantID] = executor.NewChRunner(conn)
|
||||||
|
reg.closers = append(reg.closers, func() { _ = conn.Close() })
|
||||||
|
}
|
||||||
|
return reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases every underlying connection -- call once at process
|
||||||
|
// shutdown, same lifecycle as the single conn.Close() api/cmd/api/main.go
|
||||||
|
// defers today, just fanned out over N connections.
|
||||||
|
func (r *Registry) Close() {
|
||||||
|
for _, c := range r.closers {
|
||||||
|
c()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSQL implements executor.SQLRunner. Resolves the caller's tenant
|
||||||
|
// from ctx (never a parameter -- see this file's doc comment) and fails
|
||||||
|
// closed on every ambiguous case: no identity, an identity with no
|
||||||
|
// tenant (RoleService, or a misconfigured authorizer), or a tenant with
|
||||||
|
// no provisioned connection all return an error, never a fallback to
|
||||||
|
// some other tenant's connection or an arbitrarily-chosen default.
|
||||||
|
func (r *Registry) RunSQL(ctx context.Context, sql string) (*executor.Result, error) {
|
||||||
|
identity, ok := authz.IdentityFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("chrunner: no authenticated identity in context, refusing to run query")
|
||||||
|
}
|
||||||
|
if identity.TenantID == "" {
|
||||||
|
return nil, fmt.Errorf("chrunner: authenticated identity %q has no tenant, refusing to run query", identity.Role)
|
||||||
|
}
|
||||||
|
runner, ok := r.runners[identity.TenantID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("chrunner: tenant %q has no provisioned ClickHouse connection", identity.TenantID)
|
||||||
|
}
|
||||||
|
return runner.RunSQL(ctx, sql)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Integration tests against a real ClickHouse -- exercises the actual
|
||||||
|
// question this package exists to answer: does a query authenticated as
|
||||||
|
// tenant A ever see tenant B's data. Uses enterprise/internal/
|
||||||
|
// tenantprovision to set up real per-tenant users first (this is the
|
||||||
|
// adversarial probe api/queryapi/tenant_isolation_gap_test.go's
|
||||||
|
// TestAdversarial_ClickHouseUserCannotReadOtherTenantDatabaseByFullyQualifiedName
|
||||||
|
// names as blocked -- this is where it stops being blocked, at the
|
||||||
|
// chrunner/query-execution layer specifically, complementing
|
||||||
|
// tenantprovision's own version of the same probe at the raw-SQL-user
|
||||||
|
// layer).
|
||||||
|
//
|
||||||
|
// Skipped unless CHRUNNER_TEST_CLICKHOUSE_ADDR is set; run via:
|
||||||
|
//
|
||||||
|
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/enterprise \
|
||||||
|
// -e CHRUNNER_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
|
||||||
|
// -e CHRUNNER_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
|
||||||
|
// golang:1.25-alpine go test ./internal/chrunner/... -v
|
||||||
|
package chrunner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
chdriver "github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sentry/sentry/api/authz"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/enterprise/internal/tenantprovision"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testAddr(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
addr := os.Getenv("CHRUNNER_TEST_CLICKHOUSE_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
t.Skip("CHRUNNER_TEST_CLICKHOUSE_ADDR not set -- skipping live-ClickHouse integration test")
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func provisionTestTenant(t *testing.T, addr string) (tenantID string, creds tenantprovision.Credentials) {
|
||||||
|
t.Helper()
|
||||||
|
admin, err := chdriver.Open(&chdriver.Options{
|
||||||
|
Addr: []string{addr},
|
||||||
|
Auth: chdriver.Auth{Database: "default", Username: "default", Password: os.Getenv("CHRUNNER_TEST_CLICKHOUSE_PASSWORD")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening admin connection: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { admin.Close() })
|
||||||
|
|
||||||
|
tenantID = "cr" + uuid.NewString()[:8]
|
||||||
|
creds, err = tenantprovision.New(admin).ProvisionClickHouse(context.Background(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provisioning tenant %s: %v", tenantID, err)
|
||||||
|
}
|
||||||
|
return tenantID, creds
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryRoutesQueryToCorrectTenant(t *testing.T) {
|
||||||
|
addr := testAddr(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA, credsA := provisionTestTenant(t, addr)
|
||||||
|
|
||||||
|
// Seed a distinguishing row directly as the tenant (SELECT-only
|
||||||
|
// grant means chrunner's own connection can't INSERT -- use a
|
||||||
|
// throwaway admin connection to seed data, matching how a real
|
||||||
|
// deployment's ingest path would write, not how api's read-only
|
||||||
|
// query path does).
|
||||||
|
admin, err := chdriver.Open(&chdriver.Options{
|
||||||
|
Addr: []string{addr},
|
||||||
|
Auth: chdriver.Auth{Database: "default", Username: "default", Password: os.Getenv("CHRUNNER_TEST_CLICKHOUSE_PASSWORD")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening admin connection: %v", err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
if err := admin.Exec(ctx, fmt.Sprintf("CREATE TABLE `%s`.marker (id UInt8) ENGINE = Memory", tenantA)); err != nil {
|
||||||
|
t.Fatalf("creating marker table: %v", err)
|
||||||
|
}
|
||||||
|
if err := admin.Exec(ctx, fmt.Sprintf("INSERT INTO `%s`.marker VALUES (42)", tenantA)); err != nil {
|
||||||
|
t.Fatalf("seeding marker row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := New(ctx, addr, []DataSource{
|
||||||
|
{TenantID: tenantA, Database: tenantA, Username: credsA.Username, Password: credsA.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
reqCtx := authz.WithIdentity(ctx, authz.Identity{TenantID: tenantA, Role: authz.RoleViewer})
|
||||||
|
result, err := reg.RunSQL(reqCtx, "SELECT id FROM marker")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunSQL: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Rows) != 1 || result.Rows[0][0] != uint8(42) {
|
||||||
|
t.Fatalf("unexpected result: %+v", result.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryRefusesQueryWithNoTenantContext(t *testing.T) {
|
||||||
|
addr := testAddr(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA, credsA := provisionTestTenant(t, addr)
|
||||||
|
|
||||||
|
reg, err := New(ctx, addr, []DataSource{
|
||||||
|
{TenantID: tenantA, Database: tenantA, Username: credsA.Username, Password: credsA.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
if _, err := reg.RunSQL(ctx, "SELECT 1"); err == nil {
|
||||||
|
t.Fatal("expected RunSQL to refuse a request with no authenticated identity in context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryRefusesUnknownTenant(t *testing.T) {
|
||||||
|
addr := testAddr(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA, credsA := provisionTestTenant(t, addr)
|
||||||
|
|
||||||
|
reg, err := New(ctx, addr, []DataSource{
|
||||||
|
{TenantID: tenantA, Database: tenantA, Username: credsA.Username, Password: credsA.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
reqCtx := authz.WithIdentity(ctx, authz.Identity{TenantID: "some-other-tenant-never-provisioned", Role: authz.RoleViewer})
|
||||||
|
if _, err := reg.RunSQL(reqCtx, "SELECT 1"); err == nil {
|
||||||
|
t.Fatal("expected RunSQL to refuse a tenant with no provisioned connection, not silently fall back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL is the full
|
||||||
|
// end-to-end adversarial probe: two tenants, two connections inside one
|
||||||
|
// Registry, and a raw-SQL attempt (which the query language's escape
|
||||||
|
// hatch would pass straight through unmodified) to read the other
|
||||||
|
// tenant's data by fully-qualified name. This is what proves the
|
||||||
|
// connection-layer isolation model actually holds through chrunner, not
|
||||||
|
// just through tenantprovision's own grants (already covered by
|
||||||
|
// tenantprovision_test.go) -- this test exercises the exact code path
|
||||||
|
// api/queryapi.Handler calls in production.
|
||||||
|
func TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL(t *testing.T) {
|
||||||
|
addr := testAddr(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA, credsA := provisionTestTenant(t, addr)
|
||||||
|
tenantB, credsB := provisionTestTenant(t, addr)
|
||||||
|
|
||||||
|
admin, err := chdriver.Open(&chdriver.Options{
|
||||||
|
Addr: []string{addr},
|
||||||
|
Auth: chdriver.Auth{Database: "default", Username: "default", Password: os.Getenv("CHRUNNER_TEST_CLICKHOUSE_PASSWORD")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening admin connection: %v", err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
if err := admin.Exec(ctx, fmt.Sprintf("CREATE TABLE `%s`.secret (id UInt8) ENGINE = Memory", tenantB)); err != nil {
|
||||||
|
t.Fatalf("creating secret table: %v", err)
|
||||||
|
}
|
||||||
|
if err := admin.Exec(ctx, fmt.Sprintf("INSERT INTO `%s`.secret VALUES (99)", tenantB)); err != nil {
|
||||||
|
t.Fatalf("seeding secret row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := New(ctx, addr, []DataSource{
|
||||||
|
{TenantID: tenantA, Database: tenantA, Username: credsA.Username, Password: credsA.Password},
|
||||||
|
{TenantID: tenantB, Database: tenantB, Username: credsB.Username, Password: credsB.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
reqCtx := authz.WithIdentity(ctx, authz.Identity{TenantID: tenantA, Role: authz.RoleViewer})
|
||||||
|
_, err = reg.RunSQL(reqCtx, fmt.Sprintf("SELECT * FROM `%s`.secret", tenantB))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("tenant A's request was able to read tenant B's database by fully-qualified name -- isolation is broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,11 @@
|
|||||||
// that handler itself isn't built yet (see cmd/enterprise-auth/main.go's
|
// that handler itself isn't built yet (see cmd/enterprise-auth/main.go's
|
||||||
// doc comment), so today rbacstore's only production caller is
|
// doc comment), so today rbacstore's only production caller is
|
||||||
// -mint-service-token's future tenant-aware successor and its own tests.
|
// -mint-service-token's future tenant-aware successor and its own tests.
|
||||||
// dashboard_permissions and data_sources (also part of the schema) don't
|
// dashboard_permissions doesn't have CRUD here yet -- no caller reads
|
||||||
// have CRUD here yet -- no caller needs them until dashboards' handler
|
// per-resource grants (see api/dashboards/handler.go's doc
|
||||||
// wiring reads per-resource grants, named as deferred in task 5's
|
// comment). data_sources CRUD was added once enterprise/internal/
|
||||||
// summary.
|
// chrunner needed a real place to read per-tenant ClickHouse credentials
|
||||||
|
// from at startup (see that package's doc comment).
|
||||||
package rbacstore
|
package rbacstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -44,17 +45,17 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string
|
ID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Status string
|
Status string
|
||||||
OwnerUserID string // empty until a first Owner is assigned
|
OwnerUserID string // empty until a first Owner is assigned
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role mirrors api/internal/authz.Role's string values, kept as a plain
|
// Role mirrors api/authz.Role's string values, kept as a plain
|
||||||
// string here rather than importing authz -- rbacstore is enterprise
|
// string here rather than importing authz -- rbacstore is enterprise
|
||||||
// code and api/internal/authz is core; enterprise may depend on
|
// code and api/authz is core; enterprise may depend on
|
||||||
// nothing-shaped-like-an-import-from-core per the module boundary
|
// nothing-shaped-like-an-import-from-core per the module boundary
|
||||||
// (see /docs/phase-4-isolation-design.md), even though the reverse
|
// (see /docs/phase-4-isolation-design.md), even though the reverse
|
||||||
// (core importing enterprise) is the one hack/check-tenant-boundary.sh
|
// (core importing enterprise) is the one hack/check-tenant-boundary.sh
|
||||||
@@ -252,3 +253,114 @@ func (s *Store) ListMembershipsForUser(ctx context.Context, userID string) ([]Me
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DataSource is one tenant's data-plane location -- today, exactly one
|
||||||
|
// ClickHouse database + one Tantivy index per tenant (see
|
||||||
|
// /docs/phase-4-rbac-design.md's "data_sources" extension-point
|
||||||
|
// section). ClickHouseUsername/Password are nil until
|
||||||
|
// enterprise/internal/tenantprovision actually provisions the
|
||||||
|
// ClickHouse-side user/database and calls SetDataSourceClickHouseCredentials.
|
||||||
|
type DataSource struct {
|
||||||
|
ID string
|
||||||
|
TenantID string
|
||||||
|
Name string
|
||||||
|
ClickHouseDatabaseName string
|
||||||
|
TantivyIndexPath string
|
||||||
|
ClickHouseUsername *string
|
||||||
|
ClickHousePassword *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDataSource inserts the row tenantprovision will later attach
|
||||||
|
// credentials to (SetDataSourceClickHouseCredentials) -- split into two
|
||||||
|
// steps because the row (database name, index path) is decided before
|
||||||
|
// provisioning runs, but the ClickHouse-side username/password only
|
||||||
|
// exist after CREATE USER actually succeeds.
|
||||||
|
func (s *Store) CreateDataSource(ctx context.Context, tenantID, name, clickHouseDatabaseName, tantivyIndexPath string) (*DataSource, error) {
|
||||||
|
ds := DataSource{
|
||||||
|
ID: uuid.NewString(), TenantID: tenantID, Name: name,
|
||||||
|
ClickHouseDatabaseName: clickHouseDatabaseName, TantivyIndexPath: tantivyIndexPath,
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO data_sources (id, tenant_id, name, clickhouse_database_name, tantivy_index_path)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
ds.ID, ds.TenantID, ds.Name, ds.ClickHouseDatabaseName, ds.TantivyIndexPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rbacstore: creating data source: %w", err)
|
||||||
|
}
|
||||||
|
return &ds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDataSourceClickHouseCredentials is the only way
|
||||||
|
// clickhouse_username/password change -- called once, right after
|
||||||
|
// enterprise/internal/tenantprovision.ProvisionClickHouse succeeds.
|
||||||
|
// Never called again for the same data source: rotating a live tenant's
|
||||||
|
// credential without first updating it on the ClickHouse side would
|
||||||
|
// just break every open connection, same reasoning as
|
||||||
|
// deploy/operator/internal/controller/tenant_controller.go's
|
||||||
|
// reconcileSecret.
|
||||||
|
func (s *Store) SetDataSourceClickHouseCredentials(ctx context.Context, id, username, password string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE data_sources SET clickhouse_username = $2, clickhouse_password = $3 WHERE id = $1`,
|
||||||
|
id, username, password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rbacstore: setting data source credentials: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDataSource(row pgx.Row) (*DataSource, error) {
|
||||||
|
var ds DataSource
|
||||||
|
if err := row.Scan(&ds.ID, &ds.TenantID, &ds.Name, &ds.ClickHouseDatabaseName, &ds.TantivyIndexPath,
|
||||||
|
&ds.ClickHouseUsername, &ds.ClickHousePassword); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetDataSourceForTenant(ctx context.Context, tenantID string) (*DataSource, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_id, name, clickhouse_database_name, tantivy_index_path, clickhouse_username, clickhouse_password
|
||||||
|
FROM data_sources WHERE tenant_id = $1 ORDER BY created_at LIMIT 1`, tenantID)
|
||||||
|
ds, err := scanDataSource(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("rbacstore: getting data source: %w", err)
|
||||||
|
}
|
||||||
|
return ds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProvisionedDataSources returns every data source for an active
|
||||||
|
// tenant that has already been provisioned (ClickHouse credentials set)
|
||||||
|
// -- exactly the set enterprise/internal/chrunner.NewRegistry needs at
|
||||||
|
// startup. A data source with no credentials yet (tenantprovision hasn't
|
||||||
|
// run for it) is deliberately excluded rather than returned with empty
|
||||||
|
// credentials -- chrunner has nothing safe to connect with for it, and
|
||||||
|
// silently including it would turn into a confusing empty-string
|
||||||
|
// connection attempt instead of a clear "not provisioned yet" absence.
|
||||||
|
func (s *Store) ListProvisionedDataSources(ctx context.Context) ([]DataSource, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT ds.id, ds.tenant_id, ds.name, ds.clickhouse_database_name, ds.tantivy_index_path,
|
||||||
|
ds.clickhouse_username, ds.clickhouse_password
|
||||||
|
FROM data_sources ds
|
||||||
|
JOIN tenants t ON t.id = ds.tenant_id
|
||||||
|
WHERE t.status = 'active' AND ds.clickhouse_username IS NOT NULL AND ds.clickhouse_password IS NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rbacstore: listing provisioned data sources: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []DataSource
|
||||||
|
for rows.Next() {
|
||||||
|
ds, err := scanDataSource(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rbacstore: scanning data source: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, *ds)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,3 +225,124 @@ func TestListMembershipsForUserAcrossTenants(t *testing.T) {
|
|||||||
t.Fatalf("got %d memberships, want 2: %+v", len(memberships), memberships)
|
t.Fatalf("got %d memberships, want 2: %+v", len(memberships), memberships)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateDataSourceThenSetCredentials(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantID := "test-tenant-" + uniqueSuffix()
|
||||||
|
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||||
|
t.Fatalf("CreateTenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ds, err := s.CreateDataSource(ctx, tenantID, "default", tenantID, "/var/lib/sentry-search/tenants/"+tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDataSource: %v", err)
|
||||||
|
}
|
||||||
|
if ds.ClickHouseUsername != nil || ds.ClickHousePassword != nil {
|
||||||
|
t.Fatalf("new data source must have no credentials yet, got %+v", ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetDataSourceForTenant(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDataSourceForTenant: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != ds.ID || got.ClickHouseUsername != nil {
|
||||||
|
t.Fatalf("unexpected data source: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetDataSourceClickHouseCredentials(ctx, ds.ID, "tenant_"+tenantID, "secret-password"); err != nil {
|
||||||
|
t.Fatalf("SetDataSourceClickHouseCredentials: %v", err)
|
||||||
|
}
|
||||||
|
got, err = s.GetDataSourceForTenant(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDataSourceForTenant after credentials set: %v", err)
|
||||||
|
}
|
||||||
|
if got.ClickHouseUsername == nil || *got.ClickHouseUsername != "tenant_"+tenantID {
|
||||||
|
t.Fatalf("ClickHouseUsername = %v, want tenant_%s", got.ClickHouseUsername, tenantID)
|
||||||
|
}
|
||||||
|
if got.ClickHousePassword == nil || *got.ClickHousePassword != "secret-password" {
|
||||||
|
t.Fatalf("ClickHousePassword = %v, want secret-password", got.ClickHousePassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDataSourceClickHouseCredentialsNotFound(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
if err := s.SetDataSourceClickHouseCredentials(context.Background(), "does-not-exist-"+uniqueSuffix(), "u", "p"); err != ErrNotFound {
|
||||||
|
t.Fatalf("SetDataSourceClickHouseCredentials error = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDataSourceForTenantNotFound(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
if _, err := s.GetDataSourceForTenant(context.Background(), "does-not-exist-"+uniqueSuffix()); err != ErrNotFound {
|
||||||
|
t.Fatalf("GetDataSourceForTenant error = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListProvisionedDataSourcesExcludesUnprovisionedAndInactive proves
|
||||||
|
// the two filters ListProvisionedDataSources documents: a data source
|
||||||
|
// with no ClickHouse credentials yet is excluded (nothing safe to
|
||||||
|
// connect with), and a data source belonging to a non-active tenant is
|
||||||
|
// excluded too (a suspended/provisioning tenant must not show up in
|
||||||
|
// chrunner's connection registry).
|
||||||
|
func TestListProvisionedDataSourcesExcludesUnprovisionedAndInactive(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
activeProvisioned := "test-tenant-" + uniqueSuffix()
|
||||||
|
activeUnprovisioned := "test-tenant-" + uniqueSuffix()
|
||||||
|
suspended := "test-tenant-" + uniqueSuffix()
|
||||||
|
|
||||||
|
for _, id := range []string{activeProvisioned, activeUnprovisioned, suspended} {
|
||||||
|
if _, err := s.CreateTenant(ctx, id, id); err != nil {
|
||||||
|
t.Fatalf("CreateTenant %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.SetTenantStatus(ctx, activeProvisioned, "active"); err != nil {
|
||||||
|
t.Fatalf("SetTenantStatus activeProvisioned: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetTenantStatus(ctx, activeUnprovisioned, "active"); err != nil {
|
||||||
|
t.Fatalf("SetTenantStatus activeUnprovisioned: %v", err)
|
||||||
|
}
|
||||||
|
// suspended stays in 'provisioning' (CreateTenant's default) -- not active.
|
||||||
|
|
||||||
|
dsProvisioned, err := s.CreateDataSource(ctx, activeProvisioned, "default", activeProvisioned, "/idx")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDataSource activeProvisioned: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetDataSourceClickHouseCredentials(ctx, dsProvisioned.ID, "u", "p"); err != nil {
|
||||||
|
t.Fatalf("SetDataSourceClickHouseCredentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.CreateDataSource(ctx, activeUnprovisioned, "default", activeUnprovisioned, "/idx"); err != nil {
|
||||||
|
t.Fatalf("CreateDataSource activeUnprovisioned: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsSuspended, err := s.CreateDataSource(ctx, suspended, "default", suspended, "/idx")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDataSource suspended: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetDataSourceClickHouseCredentials(ctx, dsSuspended.ID, "u", "p"); err != nil {
|
||||||
|
t.Fatalf("SetDataSourceClickHouseCredentials suspended: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListProvisionedDataSources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProvisionedDataSources: %v", err)
|
||||||
|
}
|
||||||
|
foundOurs := false
|
||||||
|
for _, ds := range list {
|
||||||
|
if ds.TenantID == activeUnprovisioned {
|
||||||
|
t.Fatalf("unprovisioned data source leaked into the list: %+v", ds)
|
||||||
|
}
|
||||||
|
if ds.TenantID == suspended {
|
||||||
|
t.Fatalf("non-active tenant's data source leaked into the list: %+v", ds)
|
||||||
|
}
|
||||||
|
if ds.TenantID == activeProvisioned {
|
||||||
|
foundOurs = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundOurs {
|
||||||
|
t.Fatal("expected the active, provisioned data source to be in the list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"github.com/go-jose/go-jose/v4/jwt"
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claims mirrors api/internal/authz.Identity's fields (TenantID, UserID,
|
// Claims mirrors api/authz.Identity's fields (TenantID, UserID,
|
||||||
// Role as a string) plus the standard registered JWT claims. Role is
|
// Role as a string) plus the standard registered JWT claims. Role is
|
||||||
// deliberately a plain string, not enterprise's own type, since its only
|
// deliberately a plain string, not enterprise's own type, since its only
|
||||||
// consumer -- authz.Role -- is defined in core and this package must not
|
// consumer -- authz.Role -- is defined in core and this package must not
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Package tenantprovision does the ClickHouse-side half of tenant
|
||||||
|
// provisioning /docs/phase-4-isolation-design.md describes: one
|
||||||
|
// dedicated database + one narrowly-granted user per tenant, ordered
|
||||||
|
// and idempotent (CREATE DATABASE -> CREATE USER -> GRANT). This is the
|
||||||
|
// piece that was missing before -- deploy/operator's Tenant controller
|
||||||
|
// only manages the K8s-side credential Secret; nothing called
|
||||||
|
// ClickHouse's DDL to make that Secret's credentials actually work
|
||||||
|
// until this package.
|
||||||
|
//
|
||||||
|
// What this package does NOT do: Tantivy index provisioning (still
|
||||||
|
// unbuilt -- see /docs/security/threat-model.md), and it does not
|
||||||
|
// itself decide when a tenant becomes 'active' in rbacstore.tenants --
|
||||||
|
// the caller (enterprise-api's -provision-tenant flag) does that only
|
||||||
|
// after ProvisionClickHouse returns success, matching the ordered gate
|
||||||
|
// /docs/phase-4-isolation-design.md specifies: CREATE USER -> GRANT ->
|
||||||
|
// only then mark active.
|
||||||
|
package tenantprovision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tenantIdentifierPattern is deliberately strict: tenant IDs become
|
||||||
|
// literal ClickHouse database/user names, interpolated directly into
|
||||||
|
// DDL statements below (ClickHouse's driver has no parameterized-query
|
||||||
|
// support for identifiers, only values -- this is the real reason
|
||||||
|
// rbacstore.Tenant.ID and every K8s Tenant CRD name are constrained to
|
||||||
|
// look like a DNS-safe slug already; this regexp is the enforcement
|
||||||
|
// point specific to this package's SQL construction, not a general
|
||||||
|
// tenant-ID validator).
|
||||||
|
var tenantIdentifierPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`)
|
||||||
|
|
||||||
|
// Credentials is what ProvisionClickHouse hands back for the caller to
|
||||||
|
// persist (rbacstore.Store.SetDataSourceClickHouseCredentials) --
|
||||||
|
// Password is returned exactly once; ClickHouse itself doesn't store it
|
||||||
|
// recoverably, so losing this return value means re-provisioning
|
||||||
|
// (dropping and recreating the user) is the only recovery path.
|
||||||
|
type Credentials struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisioner wraps an admin ClickHouse connection -- one with
|
||||||
|
// access_management enabled, the same credential docker-compose.yml's
|
||||||
|
// CLICKHOUSE_PASSWORD/the Helm chart's clickhouse Secret already is.
|
||||||
|
// Never the per-tenant connections enterprise/internal/chrunner opens.
|
||||||
|
type Provisioner struct {
|
||||||
|
admin driver.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(admin driver.Conn) *Provisioner {
|
||||||
|
return &Provisioner{admin: admin}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionClickHouse creates tenantID's database and a fresh user for
|
||||||
|
// it, granted SELECT on exactly that database and nothing else.
|
||||||
|
// Database creation is idempotent (CREATE DATABASE IF NOT EXISTS --
|
||||||
|
// harmless to repeat). User creation deliberately is NOT idempotent
|
||||||
|
// (plain CREATE USER, no IF NOT EXISTS): ClickHouse has no way to read
|
||||||
|
// back an existing user's password, so silently succeeding on a second
|
||||||
|
// call would either mean returning stale/wrong credentials or silently
|
||||||
|
// rotating a live tenant's password out from under it -- the same
|
||||||
|
// "never rotate a live credential without coordinating the consumer
|
||||||
|
// side" reasoning as deploy/operator/internal/controller/
|
||||||
|
// tenant_controller.go's reconcileSecret. A second call for an
|
||||||
|
// already-provisioned tenant fails loudly instead, which is the correct
|
||||||
|
// outcome: the caller (enterprise-api's -provision-tenant flag) must
|
||||||
|
// check rbacstore for existing credentials before ever calling this,
|
||||||
|
// not rely on this function to be safely re-callable.
|
||||||
|
//
|
||||||
|
// system.* access: intentionally not explicitly granted anywhere here,
|
||||||
|
// relying on ClickHouse RBAC's default-deny for a freshly created user
|
||||||
|
// once access_management is enabled on the admin connection (required
|
||||||
|
// for CREATE USER/GRANT to work at all). This is exactly the assumption
|
||||||
|
// /docs/phase-4-isolation-design.md's task 2 finding says must be
|
||||||
|
// verified live per ClickHouse version, not trusted from documentation
|
||||||
|
// -- see /docs/security/threat-model.md's "system.query_log metadata
|
||||||
|
// leakage" section; that verification has not happened yet.
|
||||||
|
func (p *Provisioner) ProvisionClickHouse(ctx context.Context, tenantID string) (Credentials, error) {
|
||||||
|
if !tenantIdentifierPattern.MatchString(tenantID) {
|
||||||
|
return Credentials{}, fmt.Errorf("tenantprovision: tenant id %q is not a safe ClickHouse identifier", tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
database := tenantID
|
||||||
|
username := "tenant_" + tenantID
|
||||||
|
|
||||||
|
if err := p.admin.Exec(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", database)); err != nil {
|
||||||
|
return Credentials{}, fmt.Errorf("tenantprovision: creating database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := generatePassword()
|
||||||
|
if err != nil {
|
||||||
|
return Credentials{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// No IF NOT EXISTS -- see this function's doc comment for why a
|
||||||
|
// second call must fail, not silently succeed.
|
||||||
|
if err := p.admin.Exec(ctx, fmt.Sprintf(
|
||||||
|
"CREATE USER `%s` IDENTIFIED WITH plaintext_password BY '%s'",
|
||||||
|
username, escapeSingleQuotes(password),
|
||||||
|
)); err != nil {
|
||||||
|
return Credentials{}, fmt.Errorf("tenantprovision: creating user (already provisioned? this call is not safe to retry): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.admin.Exec(ctx, fmt.Sprintf("GRANT SELECT ON `%s`.* TO `%s`", database, username)); err != nil {
|
||||||
|
return Credentials{}, fmt.Errorf("tenantprovision: granting select: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Credentials{Username: username, Password: password}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePassword() (string, error) {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("tenantprovision: generating password: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeSingleQuotes guards against a generated password (base64
|
||||||
|
// RawURLEncoding, so alphanumeric plus '-'/'_' only, never a literal
|
||||||
|
// quote) accidentally breaking out of the SQL string literal -- belt
|
||||||
|
// and suspenders given generatePassword's actual alphabet can't produce
|
||||||
|
// one, since this function's output gets interpolated directly into DDL
|
||||||
|
// (see tenantIdentifierPattern's doc comment on why: ClickHouse's driver
|
||||||
|
// has no parameterized identifiers/literals for DDL).
|
||||||
|
func escapeSingleQuotes(s string) string {
|
||||||
|
out := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\'' {
|
||||||
|
out = append(out, '\'', '\'')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s[i])
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Integration tests against a real ClickHouse -- this package's whole
|
||||||
|
// job is DDL side effects (CREATE DATABASE/USER, GRANT), which a mock
|
||||||
|
// driver.Conn can't meaningfully verify. Skipped unless
|
||||||
|
// TENANTPROVISION_TEST_CLICKHOUSE_ADDR is set; run via:
|
||||||
|
//
|
||||||
|
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/enterprise \
|
||||||
|
// -e TENANTPROVISION_TEST_CLICKHOUSE_ADDR=clickhouse:9000 \
|
||||||
|
// -e TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD=sentry-dev-only \
|
||||||
|
// golang:1.25-alpine go test ./internal/tenantprovision/... -v
|
||||||
|
package tenantprovision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testAdminConn(t *testing.T) driver.Conn {
|
||||||
|
t.Helper()
|
||||||
|
addr := os.Getenv("TENANTPROVISION_TEST_CLICKHOUSE_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
t.Skip("TENANTPROVISION_TEST_CLICKHOUSE_ADDR not set -- skipping live-ClickHouse integration test")
|
||||||
|
}
|
||||||
|
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
|
Addr: []string{addr},
|
||||||
|
Auth: clickhouse.Auth{
|
||||||
|
Database: "default",
|
||||||
|
Username: "default",
|
||||||
|
Password: os.Getenv("TENANTPROVISION_TEST_CLICKHOUSE_PASSWORD"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening admin connection: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
if err := conn.Ping(context.Background()); err != nil {
|
||||||
|
t.Fatalf("pinging clickhouse: %v", err)
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTenantID() string {
|
||||||
|
return "tp" + uuid.NewString()[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionClickHouseCreatesUsableTenantConnection(t *testing.T) {
|
||||||
|
admin := testAdminConn(t)
|
||||||
|
p := New(admin)
|
||||||
|
tenantID := testTenantID()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
creds, err := p.ProvisionClickHouse(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionClickHouse: %v", err)
|
||||||
|
}
|
||||||
|
if creds.Username != "tenant_"+tenantID || creds.Password == "" {
|
||||||
|
t.Fatalf("unexpected credentials: %+v", creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prove the credential actually works: connect as the tenant user
|
||||||
|
// and run a real query against its own database.
|
||||||
|
tenantConn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
|
Addr: []string{os.Getenv("TENANTPROVISION_TEST_CLICKHOUSE_ADDR")},
|
||||||
|
Auth: clickhouse.Auth{Database: tenantID, Username: creds.Username, Password: creds.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening tenant connection: %v", err)
|
||||||
|
}
|
||||||
|
defer tenantConn.Close()
|
||||||
|
if err := tenantConn.Ping(ctx); err != nil {
|
||||||
|
t.Fatalf("pinging as the provisioned tenant user: %v", err)
|
||||||
|
}
|
||||||
|
if err := tenantConn.Exec(ctx, "SELECT 1"); err != nil {
|
||||||
|
t.Fatalf("running SELECT as the provisioned tenant user: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProvisionedUserCannotReadOtherTenantDatabase is one of the four
|
||||||
|
// adversarial probes /docs/phase-4-isolation-design.md's verification
|
||||||
|
// plan names for Phase 4 task 8 (see api/queryapi/
|
||||||
|
// tenant_isolation_gap_test.go, which stubs this exact scenario as
|
||||||
|
// blocked pending tenantprovision existing) -- now that tenantprovision
|
||||||
|
// exists, this is the first one that can actually run for real.
|
||||||
|
func TestProvisionedUserCannotReadOtherTenantDatabase(t *testing.T) {
|
||||||
|
admin := testAdminConn(t)
|
||||||
|
p := New(admin)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tenantA := testTenantID()
|
||||||
|
tenantB := testTenantID()
|
||||||
|
credsA, err := p.ProvisionClickHouse(ctx, tenantA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provisioning tenant A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.ProvisionClickHouse(ctx, tenantB); err != nil {
|
||||||
|
t.Fatalf("provisioning tenant B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a row in tenant B's database as admin.
|
||||||
|
if err := admin.Exec(ctx, fmt.Sprintf("CREATE TABLE `%s`.secret (id UInt8) ENGINE = Memory", tenantB)); err != nil {
|
||||||
|
t.Fatalf("creating table in tenant B's database: %v", err)
|
||||||
|
}
|
||||||
|
if err := admin.Exec(ctx, fmt.Sprintf("INSERT INTO `%s`.secret VALUES (1)", tenantB)); err != nil {
|
||||||
|
t.Fatalf("inserting into tenant B's database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantAConn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
|
Addr: []string{os.Getenv("TENANTPROVISION_TEST_CLICKHOUSE_ADDR")},
|
||||||
|
Auth: clickhouse.Auth{Database: tenantA, Username: credsA.Username, Password: credsA.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening tenant A connection: %v", err)
|
||||||
|
}
|
||||||
|
defer tenantAConn.Close()
|
||||||
|
|
||||||
|
// The core adversarial probe: tenant A's user attempting to read
|
||||||
|
// tenant B's database by fully-qualified name in raw SQL.
|
||||||
|
err = tenantAConn.Exec(ctx, fmt.Sprintf("SELECT * FROM `%s`.secret", tenantB))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("tenant A's user was able to read tenant B's database -- isolation is broken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProvisionedUserCannotReadSystemTables is item 2 of
|
||||||
|
// /docs/phase-4-isolation-design.md's verification plan (see
|
||||||
|
// api/queryapi/tenant_isolation_gap_test.go for the other three items'
|
||||||
|
// status) -- task 2's finding was that system.* visibility for a
|
||||||
|
// non-admin ClickHouse user is version-dependent and must be checked
|
||||||
|
// live, not assumed from documentation. ProvisionClickHouse never
|
||||||
|
// explicitly grants system.* access to anything (see its doc comment);
|
||||||
|
// this test is what actually confirms that omission is sufficient on
|
||||||
|
// the ClickHouse version this repo pins (docker-compose.yml:
|
||||||
|
// clickhouse/clickhouse-server:24.8), rather than trusting the omission
|
||||||
|
// alone.
|
||||||
|
func TestProvisionedUserCannotReadSystemTables(t *testing.T) {
|
||||||
|
admin := testAdminConn(t)
|
||||||
|
p := New(admin)
|
||||||
|
tenantID := testTenantID()
|
||||||
|
creds, err := p.ProvisionClickHouse(context.Background(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionClickHouse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantConn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
|
Addr: []string{os.Getenv("TENANTPROVISION_TEST_CLICKHOUSE_ADDR")},
|
||||||
|
Auth: clickhouse.Auth{Database: tenantID, Username: creds.Username, Password: creds.Password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening tenant connection: %v", err)
|
||||||
|
}
|
||||||
|
defer tenantConn.Close()
|
||||||
|
|
||||||
|
// system.query_log/system.tables: expect a hard access-denied error,
|
||||||
|
// not a filtered/empty result -- these tables contain other
|
||||||
|
// tenants' query text and schema, so "succeeds but happens to
|
||||||
|
// return nothing for this user" would still be a version-dependent
|
||||||
|
// assumption worth catching, not something this test treats as a pass.
|
||||||
|
for _, probe := range []string{
|
||||||
|
"SELECT * FROM system.query_log LIMIT 1",
|
||||||
|
"SELECT * FROM system.tables LIMIT 1",
|
||||||
|
} {
|
||||||
|
if err := tenantConn.Exec(context.Background(), probe); err == nil {
|
||||||
|
t.Errorf("tenant user was able to run %q -- system.* access was not actually revoked on this ClickHouse version", probe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHOW DATABASES is checked differently: some ClickHouse versions
|
||||||
|
// filter this to only databases the user can see rather than
|
||||||
|
// erroring outright, which is an acceptable outcome for this
|
||||||
|
// specific statement (unlike query_log/tables above) as long as it
|
||||||
|
// doesn't reveal other tenants' database names.
|
||||||
|
rows, err := tenantConn.Query(context.Background(), "SHOW DATABASES")
|
||||||
|
if err != nil {
|
||||||
|
return // erroring outright is also an acceptable outcome here.
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var db string
|
||||||
|
if err := rows.Scan(&db); err != nil {
|
||||||
|
t.Fatalf("scanning SHOW DATABASES row: %v", err)
|
||||||
|
}
|
||||||
|
if db != tenantID && db != "default" && db != "system" && db != "INFORMATION_SCHEMA" && db != "information_schema" {
|
||||||
|
t.Errorf("SHOW DATABASES revealed a database this tenant user shouldn't see: %q", db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionClickHouseRejectsUnsafeTenantID(t *testing.T) {
|
||||||
|
admin := testAdminConn(t)
|
||||||
|
p := New(admin)
|
||||||
|
if _, err := p.ProvisionClickHouse(context.Background(), "not safe; DROP TABLE x"); err == nil {
|
||||||
|
t.Fatal("expected ProvisionClickHouse to reject an unsafe tenant identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvisionClickHouseSecondCallForSameTenantFails(t *testing.T) {
|
||||||
|
admin := testAdminConn(t)
|
||||||
|
p := New(admin)
|
||||||
|
tenantID := testTenantID()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := p.ProvisionClickHouse(ctx, tenantID); err != nil {
|
||||||
|
t.Fatalf("first ProvisionClickHouse: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.ProvisionClickHouse(ctx, tenantID); err == nil {
|
||||||
|
t.Fatal("expected a second ProvisionClickHouse call for the same tenant to fail -- see the function's doc comment on why re-provisioning must not silently succeed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Per-tenant ClickHouse credentials, generated by
|
||||||
|
-- enterprise/internal/tenantprovision and consumed by
|
||||||
|
-- enterprise/internal/chrunner's per-tenant connection registry at
|
||||||
|
-- enterprise-api startup. Stored in the same place every other
|
||||||
|
-- control-plane secret in this schema lives (audit_writer's password is
|
||||||
|
-- an env var, not a DB row, since it's one shared credential -- these
|
||||||
|
-- are per-tenant and need to be looked up by tenant, hence a table).
|
||||||
|
-- Same trust model as CLICKHOUSE_PASSWORD already being plaintext in
|
||||||
|
-- docker-compose.yml/the Helm chart's Secret -- Postgres itself is
|
||||||
|
-- already trusted infrastructure in this design, not a new exposure.
|
||||||
|
ALTER TABLE data_sources ADD COLUMN IF NOT EXISTS clickhouse_username TEXT;
|
||||||
|
ALTER TABLE data_sources ADD COLUMN IF NOT EXISTS clickhouse_password TEXT;
|
||||||
Reference in New Issue
Block a user