diff --git a/CLAUDE.md b/CLAUDE.md index 0d0d712..a0c13d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ once built. ## What "done" looks like for Phase 3 -A user can build a multi-panel dashboard from saved Phase 2 queries (at +**Status: shipped.** A user can build a multi-panel dashboard from saved Phase 2 queries (at least a line chart panel and a table panel, working end-to-end against live data), save an alert rule that fires a Slack webhook when a condition is met (threshold comparison, or "absence" — the query returned @@ -119,9 +119,16 @@ ClickHouse/Tantivy only, unchanged. Non-goals for this phase (same discipline as every phase so far): - No multi-tenancy enforcement and no `enterprise/` module work — single - tenant/org assumed. New tables carry a `tenant_id` column so Phase 4's - retrofit doesn't require a schema migration + backfill, but nothing - reads or enforces it yet. + tenant/org assumed. Most new tables (`dashboards`, `alert_rules`, + `notification_targets`) carry a `tenant_id` column so part of Phase 4's + retrofit doesn't require a migration + backfill — but `alert_state` and + `delivery_log` do not (an inconsistency found during Phase 4 planning, + not caught at the time); Phase 4 adds `tenant_id` to those two and + backfills via a join through `alert_rules.id`, and — per + `/docs/phase-4-isolation-design.md` — tenant isolation itself turned + out to live at the ClickHouse/Tantivy connection layer, not via these + columns at all, since Phase 2's raw-SQL escape hatch can never be + covered by a row filter regardless of which tables carry one. - No raw-SQL dashboard panels (time-range injection isn't reliable against arbitrary SQL) — pipe-syntax queries only. - No per-group/multi-row threshold alerting (e.g. "alert separately per @@ -131,6 +138,70 @@ Non-goals for this phase (same discipline as every phase so far): - No Kubernetes Operator/Helm deployment work — still docker-compose, `/deploy` remains stubbed. +## What "done" looks like for Phase 4 + +**Status: in progress, not shipped.** Through task 8: RBAC enforcement +(`api/internal/authz`), the `alerting`↔`api` service-identity credential, +tenant-scoped dashboards, and append-only audit logging are built and +tested (including live-Postgres verification for audit logging and +rbacstore). The two items this phase's exit criteria below actually +hinge on are **not** built: SSO login (OIDC/SAML protocol wiring exists; +no HTTP login handler calls it) and — the highest-risk one — tenant +isolation for log data itself (every tenant's `/query` still executes +against one shared ClickHouse connection and Tantivy index; RBAC +controls who can query, not what a query can see). 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 +bar this phase is aiming at, not a completed state. + +Two tenants can be provisioned with SSO (OIDC or SAML), each with their +own users, roles, dashboards, and alert rules, fully isolated at the +ClickHouse/Tantivy connection layer — not by a row filter — with +adversarial integration tests proving no cross-tenant data leakage, +including via the raw-SQL escape hatch and ClickHouse's own `system.*` +tables. A tenant admin can see a query audit trail for their tenant, +backed by append-only storage a compromised application credential +cannot alter (enforced by database grants, not just convention) and +periodically anchored outside the database so tampering is detectable +even against a privileged attacker. See `/docs/phase-4-isolation-design.md` +for the tenant isolation model and why it lives at the connection layer, +`/docs/phase-4-rbac-design.md` for the role/permission model, and +`/docs/security/threat-model.md` for the auth flows and audit-log +integrity guarantees, written for a prospective enterprise customer's +security team. + +The tenant-isolation, provisioning, SSO, and RBAC-enforcement mechanisms +live entirely in `enterprise/` (commercial license), confirmed +explicitly rather than assumed: AGPL core (`/api`, `/alerting`, `/web`) +stays genuinely single-tenant, with no multi-tenant mechanism present at +all — `enterprise/` supplies tenant-scoped implementations of core's +already-shipped `querylang/executor.SQLRunner`/`SearchClient` interfaces +rather than core growing tenant awareness. Query-compiler-level "compile +time" enforcement, as originally proposed, turned out not to be +achievable in any module once Phase 2's opaque raw-SQL passthrough is +accounted for — the honest, implemented guarantee is that every code +path (compiled query or raw SQL) is forced through a tenant-scoped +database connection/index that the database's own access control +enforces, not a compiler-injected filter. + +Non-goals for this phase (same discipline as every phase so far): +- No deny-override permissions — per-resource grants (e.g. a specific + user getting edit access to one dashboard) are additive only; a full + allow/deny ACL system is future work. +- No data retention/deletion policy design for tenant deprovisioning — + the provisioning state machine includes a `deprovisioning` state, but + what actually happens to a deprovisioned tenant's data is a separate, + not-yet-designed compliance question. +- No general multi-cluster orchestration in `/deploy` — scoped to + proving the per-tenant ClickHouse/Tantivy isolation model works, not a + fully general multi-cluster system. +- No protection against a privileged ClickHouse/Postgres administrator — + the isolation and audit-log guarantees in this phase are structural + defenses against application-layer bugs and injection, not against + someone with database superuser access; that's an operational control, + out of scope here and named explicitly, not silently assumed away. + ## When in doubt Ask before: changing the pinned stack, adding a new external dependency that pulls in a large transitive tree, or making an architectural decision diff --git a/alerting/cmd/alerting/main.go b/alerting/cmd/alerting/main.go index 7cefcd2..228a391 100644 --- a/alerting/cmd/alerting/main.go +++ b/alerting/cmd/alerting/main.go @@ -66,7 +66,7 @@ func main() { rules := rulestore.NewStore(pgPool) targets := notifystore.NewStore(pgPool) - qc := queryclient.New(cfg.APIQueryURL) + qc := queryclient.New(cfg.APIQueryURL, cfg.APIServiceToken) handler := httpapi.NewHandler(logger, rules, targets, rules) mux := http.NewServeMux() diff --git a/alerting/internal/config/config.go b/alerting/internal/config/config.go index 802e8b2..49032da 100644 --- a/alerting/internal/config/config.go +++ b/alerting/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { HTTPListenAddr string Postgres PostgresConfig APIQueryURL string // base URL of /api, e.g. http://api:8080 -- alerting never talks to ClickHouse/Tantivy directly + APIServiceToken string // RoleService credential presented to /api's POST /query -- see queryclient.New's doc comment CORSAllowedOrigin string Evaluator EvaluatorConfig } @@ -52,6 +53,10 @@ func Load() (Config, error) { Password: getenv("POSTGRES_PASSWORD", ""), }, APIQueryURL: getenv("API_QUERY_URL", "http://localhost:8080"), + // Empty by default -- matches Phase 0-3 behavior for a + // single-tenant deployment with no enterprise/ deployed (api's + // authorizer is nil there, so an absent token is fine). + APIServiceToken: getenv("API_SERVICE_TOKEN", ""), // Same "no auth yet" tradeoff as api's CORSAllowedOrigin default -- // see api/internal/config/config.go's comment, same reasoning here. CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"), diff --git a/alerting/internal/queryclient/client.go b/alerting/internal/queryclient/client.go index 800b16c..f4adc60 100644 --- a/alerting/internal/queryclient/client.go +++ b/alerting/internal/queryclient/client.go @@ -23,12 +23,20 @@ type errorResponse struct { } type Client struct { - baseURL string - http *http.Client + baseURL string + serviceToken string + http *http.Client } -func New(baseURL string) *Client { - return &Client{baseURL: baseURL, http: &http.Client{}} +// New builds a client for /api's POST /query. serviceToken, if non-empty, +// is sent as a Bearer credential on every request -- api's authz +// middleware resolves it (via enterprise-auth) to the RoleService +// identity described in /docs/phase-4-isolation-design.md's alerting↔api +// gap. An empty serviceToken matches Phase 0-3 behavior (no +// enterprise/ deployed, api's authorizer is nil, every request is +// allowed). +func New(baseURL, serviceToken string) *Client { + return &Client{baseURL: baseURL, serviceToken: serviceToken, http: &http.Client{}} } // Query runs query (already time-range-injected by the caller, if @@ -51,6 +59,9 @@ func (c *Client) Query(ctx context.Context, query, language string, timeout time return nil, fmt.Errorf("building query request: %w", err) } req.Header.Set("Content-Type", "application/json") + if c.serviceToken != "" { + req.Header.Set("Authorization", "Bearer "+c.serviceToken) + } resp, err := c.http.Do(req) if err != nil { diff --git a/alerting/internal/queryclient/client_test.go b/alerting/internal/queryclient/client_test.go new file mode 100644 index 0000000..08a3ece --- /dev/null +++ b/alerting/internal/queryclient/client_test.go @@ -0,0 +1,46 @@ +package queryclient + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestQuerySendsBearerServiceToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"columns":[],"rows":[]}`)) + })) + defer srv.Close() + + c := New(srv.URL, "service-token-xyz") + if _, err := c.Query(t.Context(), "stats count", "spl", time.Second); err != nil { + t.Fatalf("Query: %v", err) + } + if gotAuth != "Bearer service-token-xyz" { + t.Fatalf("Authorization header = %q, want Bearer service-token-xyz", gotAuth) + } +} + +func TestQueryOmitsAuthorizationWhenNoTokenConfigured(t *testing.T) { + var gotAuth string + sawHeader := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + sawHeader = r.Header.Get("Authorization") != "" + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"columns":[],"rows":[]}`)) + })) + defer srv.Close() + + c := New(srv.URL, "") + if _, err := c.Query(t.Context(), "stats count", "spl", time.Second); err != nil { + t.Fatalf("Query: %v", err) + } + if sawHeader { + t.Fatalf("expected no Authorization header when no service token is configured, got %q", gotAuth) + } +} diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index bc66862..d5ddd19 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -19,6 +19,7 @@ import ( "github.com/ClickHouse/clickhouse-go/v2" "github.com/jackc/pgx/v5/pgxpool" + "github.com/sentry/sentry/api/internal/authz" "github.com/sentry/sentry/api/internal/config" "github.com/sentry/sentry/api/internal/dashboards" "github.com/sentry/sentry/api/internal/httpserver" @@ -88,9 +89,19 @@ func main() { os.Exit(1) } + // authorizer is nil (RequireRole* becomes a no-op) unless + // ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior + // for a single-tenant deployment with no enterprise/ deployed. + var authorizer authz.Authorizer + if cfg.EnterpriseAuthURL != "" { + authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL) + } + sqlRunner := executor.NewChRunner(conn) - queryHandler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout) - dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool)) + // audit logging is nil (a no-op) until Phase 4 task 5 wires in + // enterprise/internal/audit -- see queryapi.AuditLogger's doc comment. + queryHandler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout, nil, authorizer) + dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer) // One shared mux, CORS applied once around the whole thing -- see // internal/httpserver's doc comment for why this changed from each diff --git a/api/internal/authz/authz.go b/api/internal/authz/authz.go new file mode 100644 index 0000000..f762a23 --- /dev/null +++ b/api/internal/authz/authz.go @@ -0,0 +1,80 @@ +// Package authz is core's RBAC extension point -- deliberately minimal +// and tenant-agnostic in shape, matching queryapi.AuditLogger's pattern: +// core defines the interface and the types, enterprise/ supplies the +// implementation. Unlike AuditLogger, the implementation Authorizer +// wires in here is NOT enterprise Go code injected directly (that would +// require api to import enterprise/, violating the module boundary +// confirmed in /docs/phase-4-isolation-design.md) -- it's an HTTP client +// to enterprise-auth's /internal/authorize endpoint (see httpauthz.go), +// the same "network boundary, not import boundary" pattern already +// established between /api and /alerting. +package authz + +import ( + "context" + "net/http" +) + +// Role is ordered for human roles (Viewer < Editor < Admin < Owner, per +// /docs/phase-4-rbac-design.md) plus a separate, non-comparable Service +// lane for machine callers like /alerting's evaluator -- see Satisfies. +type Role string + +const ( + RoleViewer Role = "viewer" + RoleEditor Role = "editor" + RoleAdmin Role = "admin" + RoleOwner Role = "owner" + RoleService Role = "service" +) + +var roleRank = map[Role]int{RoleViewer: 1, RoleEditor: 2, RoleAdmin: 3, RoleOwner: 4} + +// Satisfies reports whether this role meets a requirement. RoleService +// only ever satisfies RoleService -- a service credential never +// satisfies a human-role requirement, and a human role never satisfies +// a RoleService requirement, by design: /docs/phase-4-isolation-design.md's +// alerting service identity is deliberately not a point on the human +// role scale, so it can't accidentally inherit broader access by +// ranking above Viewer. +func (r Role) Satisfies(required Role) bool { + if required == RoleService || r == RoleService { + return r == required + } + return roleRank[r] >= roleRank[required] +} + +// Identity is what a successful Authorize call resolves. UserID is +// empty for RoleService (see /docs/phase-4-isolation-design.md's +// alerting↔api gap -- a service credential proves "this caller is +// alerting," not "this caller is acting as a specific human"). +type Identity struct { + TenantID string + UserID string + Role Role +} + +// Authorizer resolves an Identity from an incoming request's +// credentials (session cookie or service token) without knowing +// anything about *what* the caller is trying to do -- permission +// checking against a required Role happens in the middleware +// (middleware.go), not here, so this interface stays a pure +// "who is this" question. +type Authorizer interface { + Authorize(r *http.Request) (Identity, error) +} + +type identityContextKey struct{} + +// IdentityFromContext lets a handler read the resolved identity a +// RequireRole/RequireRoleOrService middleware attached -- e.g. to +// populate QueryAuditEntry's tenant/user once Phase 4's audit wiring +// threads identity through (see queryapi.AuditLogger's doc comment). +func IdentityFromContext(ctx context.Context) (Identity, bool) { + id, ok := ctx.Value(identityContextKey{}).(Identity) + return id, ok +} + +func withIdentity(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, identityContextKey{}, id) +} diff --git a/api/internal/authz/authz_test.go b/api/internal/authz/authz_test.go new file mode 100644 index 0000000..e6fa034 --- /dev/null +++ b/api/internal/authz/authz_test.go @@ -0,0 +1,36 @@ +package authz + +import "testing" + +func TestRoleSatisfies(t *testing.T) { + tests := []struct { + have, want Role + satisfies bool + }{ + {RoleViewer, RoleViewer, true}, + {RoleEditor, RoleViewer, true}, + {RoleAdmin, RoleViewer, true}, + {RoleOwner, RoleViewer, true}, + {RoleViewer, RoleEditor, false}, + {RoleViewer, RoleAdmin, false}, + {RoleEditor, RoleAdmin, false}, + {RoleAdmin, RoleOwner, false}, + {RoleOwner, RoleOwner, true}, + // RoleService is a separate lane, not on the human rank scale -- + // per /docs/phase-4-isolation-design.md's alerting service + // identity, it must never satisfy a human role requirement no + // matter how "high" that might look on paper, and a human role + // (even Owner) must never satisfy a RoleService requirement. + {RoleService, RoleViewer, false}, + {RoleService, RoleOwner, false}, + {RoleOwner, RoleService, false}, + {RoleViewer, RoleService, false}, + {RoleService, RoleService, true}, + } + for _, tt := range tests { + got := tt.have.Satisfies(tt.want) + if got != tt.satisfies { + t.Errorf("Role(%q).Satisfies(%q) = %v, want %v", tt.have, tt.want, got, tt.satisfies) + } + } +} diff --git a/api/internal/authz/httpauthz.go b/api/internal/authz/httpauthz.go new file mode 100644 index 0000000..2f1d7fb --- /dev/null +++ b/api/internal/authz/httpauthz.go @@ -0,0 +1,65 @@ +package authz + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// HTTPAuthorizer is the production Authorizer for a deployment with +// enterprise-auth configured -- it calls enterprise-auth's +// POST /internal/authorize endpoint, forwarding the caller's own +// credentials (session cookie or service-token header), rather than +// importing any enterprise/ Go package. This is the same "network +// boundary, not import boundary" shape /alerting's queryclient already +// uses to call api's /query: the module-boundary guarantee +// hack/check-tenant-boundary.sh enforces is about Go imports, and this +// type has none from enterprise/. +type HTTPAuthorizer struct { + baseURL string + http *http.Client +} + +func NewHTTPAuthorizer(baseURL string) *HTTPAuthorizer { + return &HTTPAuthorizer{baseURL: baseURL, http: &http.Client{Timeout: 3 * time.Second}} +} + +type authorizeResponse struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Role string `json:"role"` +} + +// Authorize forwards exactly two credential-carrying headers -- Cookie +// (human sessions) and Authorization (service tokens) -- never the rest +// of the request. enterprise-auth validates whichever is present and +// returns the resolved identity, or a non-2xx if neither validates. +func (a *HTTPAuthorizer) Authorize(r *http.Request) (Identity, error) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.baseURL+"/internal/authorize", nil) + if err != nil { + return Identity{}, fmt.Errorf("authz: building request: %w", err) + } + if cookie := r.Header.Get("Cookie"); cookie != "" { + req.Header.Set("Cookie", cookie) + } + if auth := r.Header.Get("Authorization"); auth != "" { + req.Header.Set("Authorization", auth) + } + + resp, err := a.http.Do(req) + if err != nil { + return Identity{}, fmt.Errorf("authz: calling enterprise-auth: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return Identity{}, fmt.Errorf("authz: enterprise-auth returned status %d", resp.StatusCode) + } + + var body authorizeResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return Identity{}, fmt.Errorf("authz: decoding response: %w", err) + } + return Identity{TenantID: body.TenantID, UserID: body.UserID, Role: Role(body.Role)}, nil +} diff --git a/api/internal/authz/httpauthz_test.go b/api/internal/authz/httpauthz_test.go new file mode 100644 index 0000000..dd4c8a5 --- /dev/null +++ b/api/internal/authz/httpauthz_test.go @@ -0,0 +1,72 @@ +package authz + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPAuthorizerForwardsCredentialsAndParsesIdentity(t *testing.T) { + var gotCookie, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCookie = r.Header.Get("Cookie") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authorizeResponse{TenantID: "acme", UserID: "u1", Role: "editor"}) + })) + defer srv.Close() + + a := NewHTTPAuthorizer(srv.URL) + incoming := httptest.NewRequest(http.MethodPost, "/query", nil) + incoming.Header.Set("Cookie", "sentry_session=abc123") + incoming.Header.Set("Authorization", "Bearer service-token-xyz") + + identity, err := a.Authorize(incoming) + if err != nil { + t.Fatalf("Authorize: %v", err) + } + if identity.TenantID != "acme" || identity.UserID != "u1" || identity.Role != RoleEditor { + t.Fatalf("unexpected identity: %+v", identity) + } + if gotCookie != "sentry_session=abc123" { + t.Fatalf("Cookie header not forwarded, got %q", gotCookie) + } + if gotAuth != "Bearer service-token-xyz" { + t.Fatalf("Authorization header not forwarded, got %q", gotAuth) + } +} + +func TestHTTPAuthorizerNon2xxIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + a := NewHTTPAuthorizer(srv.URL) + _, err := a.Authorize(httptest.NewRequest(http.MethodPost, "/query", nil)) + if err == nil { + t.Fatalf("expected an error for a 401 response from enterprise-auth") + } +} + +func TestHTTPAuthorizerDoesNotForwardUnrelatedHeaders(t *testing.T) { + var gotXForwarded string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotXForwarded = r.Header.Get("X-Forwarded-For") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authorizeResponse{Role: "viewer"}) + })) + defer srv.Close() + + a := NewHTTPAuthorizer(srv.URL) + incoming := httptest.NewRequest(http.MethodPost, "/query", nil) + incoming.Header.Set("X-Forwarded-For", "1.2.3.4") + + if _, err := a.Authorize(incoming); err != nil { + t.Fatalf("Authorize: %v", err) + } + if gotXForwarded != "" { + t.Fatalf("expected only Cookie/Authorization to be forwarded, but X-Forwarded-For leaked through as %q", gotXForwarded) + } +} diff --git a/api/internal/authz/middleware.go b/api/internal/authz/middleware.go new file mode 100644 index 0000000..4214c37 --- /dev/null +++ b/api/internal/authz/middleware.go @@ -0,0 +1,72 @@ +package authz + +import ( + "encoding/json" + "net/http" +) + +type errorResponse struct { + Error string `json:"error"` +} + +func writeUnauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(errorResponse{Error: "unauthorized"}) +} + +func writeForbidden(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(errorResponse{Error: "forbidden"}) +} + +// RequireRole wraps next so it only runs for a caller whose resolved +// Identity.Role satisfies minRole. A nil authorizer is a deliberate, +// documented no-op -- a single-tenant deployment with no enterprise/ +// configured behaves exactly as Phases 0-3 did, unauthenticated, not +// locked out. This is the same nil-safety shape as +// queryapi.AuditLogger and dashboards' optional dependencies. +func RequireRole(authorizer Authorizer, minRole Role, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if authorizer == nil { + next(w, r) + return + } + identity, err := authorizer.Authorize(r) + if err != nil { + writeUnauthorized(w) + return + } + if !identity.Role.Satisfies(minRole) { + writeForbidden(w) + return + } + next(w, r.WithContext(withIdentity(r.Context(), identity))) + } +} + +// RequireRoleOrService is RequireRole plus an explicit allowance for +// RoleService -- used only by endpoints /alerting's evaluator legitimately +// calls (POST /query today). Every other endpoint uses plain RequireRole, +// so a service credential can never reach dashboard/rule administration +// even though it's a valid, authenticated identity -- narrow by default, +// widened only where a real machine caller exists. +func RequireRoleOrService(authorizer Authorizer, minRole Role, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if authorizer == nil { + next(w, r) + return + } + identity, err := authorizer.Authorize(r) + if err != nil { + writeUnauthorized(w) + return + } + if identity.Role != RoleService && !identity.Role.Satisfies(minRole) { + writeForbidden(w) + return + } + next(w, r.WithContext(withIdentity(r.Context(), identity))) + } +} diff --git a/api/internal/authz/middleware_test.go b/api/internal/authz/middleware_test.go new file mode 100644 index 0000000..964e280 --- /dev/null +++ b/api/internal/authz/middleware_test.go @@ -0,0 +1,96 @@ +package authz + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +type fakeAuthorizer struct { + identity Identity + err error +} + +func (f *fakeAuthorizer) Authorize(_ *http.Request) (Identity, error) { + return f.identity, f.err +} + +func okHandler(w http.ResponseWriter, r *http.Request) { + id, ok := IdentityFromContext(r.Context()) + if ok { + w.Header().Set("X-Test-Tenant", id.TenantID) + } + w.WriteHeader(http.StatusOK) +} + +func TestRequireRoleNilAuthorizerIsNoOp(t *testing.T) { + h := RequireRole(nil, RoleAdmin, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (nil authorizer must be a no-op, matching single-tenant Phase 0-3 behavior)", rec.Code) + } +} + +func TestRequireRoleRejectsUnauthenticated(t *testing.T) { + h := RequireRole(&fakeAuthorizer{err: errors.New("no session")}, RoleViewer, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireRoleRejectsInsufficientRole(t *testing.T) { + h := RequireRole(&fakeAuthorizer{identity: Identity{Role: RoleViewer}}, RoleAdmin, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +func TestRequireRoleAllowsSufficientRoleAndAttachesIdentity(t *testing.T) { + h := RequireRole(&fakeAuthorizer{identity: Identity{TenantID: "acme", Role: RoleAdmin}}, RoleEditor, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("X-Test-Tenant"); got != "acme" { + t.Fatalf("expected the resolved identity to be attached to the request context, got tenant=%q", got) + } +} + +func TestRequireRoleOrServiceAllowsServiceIdentity(t *testing.T) { + h := RequireRoleOrService(&fakeAuthorizer{identity: Identity{Role: RoleService}}, RoleAdmin, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 -- RoleService must satisfy RequireRoleOrService regardless of minRole", rec.Code) + } +} + +func TestRequireRoleOrServiceStillRejectsInsufficientHumanRole(t *testing.T) { + h := RequireRoleOrService(&fakeAuthorizer{identity: Identity{Role: RoleViewer}}, RoleAdmin, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 -- allowing RoleService must not loosen the human-role check", rec.Code) + } +} + +// TestRequireRolePlainDoesNotAllowService pins down the narrow-by-default +// property middleware.go's doc comment claims: an endpoint using plain +// RequireRole (not RequireRoleOrService) must reject a RoleService +// identity even if the minRole would technically be satisfiable on the +// human scale -- RoleService never satisfies anything but itself. +func TestRequireRolePlainDoesNotAllowService(t *testing.T) { + h := RequireRole(&fakeAuthorizer{identity: Identity{Role: RoleService}}, RoleViewer, okHandler) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 -- plain RequireRole must never admit a service identity", rec.Code) + } +} diff --git a/api/internal/config/config.go b/api/internal/config/config.go index 3e83fb1..b9fbd67 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { SearchGRPCAddr string QueryTimeout time.Duration CORSAllowedOrigin string + EnterpriseAuthURL string } type ClickHouseConfig struct { @@ -58,6 +59,12 @@ func Load() (Config, error) { // working out of the box. Tighten before this is ever reachable // from outside a trusted dev/homelab network. CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"), + // Empty by default -- a single-tenant deployment without + // enterprise/ configured runs with authz.RequireRole* as a + // no-op, matching Phase 0-3 behavior. Set to enterprise-auth's + // base URL (e.g. "http://enterprise-auth:8081") to turn on + // real session/service-token enforcement. + EnterpriseAuthURL: getenv("ENTERPRISE_AUTH_URL", ""), } timeoutSec, err := strconv.Atoi(getenv("QUERY_TIMEOUT_SECONDS", "30")) diff --git a/api/internal/dashboards/handler.go b/api/internal/dashboards/handler.go index b58cbce..1636dc7 100644 --- a/api/internal/dashboards/handler.go +++ b/api/internal/dashboards/handler.go @@ -6,47 +6,80 @@ import ( "errors" "log/slog" "net/http" + + "github.com/sentry/sentry/api/internal/authz" ) // store is the narrow interface Handler depends on -- *Store (store.go) // is the production implementation; tests use a fake, same pattern as -// queryapi's SQLRunner/SearchClient. +// queryapi's SQLRunner/SearchClient. Every method except Create/Import +// takes a tenantID -- see store.go's doc comment for why. type store interface { CreateDashboard(ctx context.Context, d *Dashboard) error - ListDashboards(ctx context.Context) ([]Dashboard, error) - GetDashboard(ctx context.Context, id string) (*Dashboard, error) - UpdateDashboard(ctx context.Context, d *Dashboard) error - DeleteDashboard(ctx context.Context, id string) error - AddPanel(ctx context.Context, dashboardID string, p *Panel) error - UpdatePanel(ctx context.Context, p *Panel) error - DeletePanel(ctx context.Context, dashboardID, panelID string) error - ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error) + ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error) + GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error) + UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error + DeleteDashboard(ctx context.Context, tenantID, id string) error + AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error + UpdatePanel(ctx context.Context, tenantID string, p *Panel) error + DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error + ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error) } type Handler struct { - logger *slog.Logger - store store + logger *slog.Logger + store store + authorizer authz.Authorizer } -func NewHandler(logger *slog.Logger, store store) *Handler { - return &Handler{logger: logger, store: store} +func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler { + return &Handler{logger: logger, store: store, authorizer: authorizer} } +// RegisterRoutes wires the RBAC minimum-role bar from +// /docs/phase-4-rbac-design.md's matrix. Note what's NOT enforced here +// yet: the matrix's "(own/granted)" qualifier for Editor create/edit/ +// delete requires the dashboard_permissions/ownership lookup that +// enterprise/internal/rbacstore hasn't been built yet -- until then, +// RoleEditor is necessary but not sufficient per the matrix, and every +// Editor can act on every dashboard *within their own tenant* (tenant +// scoping itself -- a different, more basic property than the +// per-resource "(own/granted)" qualifier -- is enforced, via tenantID +// below and store.go's WHERE tenant_id = ... filtering). Tracked as +// follow-up, not silently dropped. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /dashboards", h.handleCreate) - mux.HandleFunc("GET /dashboards", h.handleList) - mux.HandleFunc("POST /dashboards/import", h.handleImport) - mux.HandleFunc("GET /dashboards/{id}", h.handleGet) - mux.HandleFunc("PUT /dashboards/{id}", h.handleUpdate) - mux.HandleFunc("DELETE /dashboards/{id}", h.handleDelete) - mux.HandleFunc("GET /dashboards/{id}/export", h.handleExport) - mux.HandleFunc("POST /dashboards/{id}/panels", h.handleAddPanel) - mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", h.handleUpdatePanel) - mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", h.handleDeletePanel) + mux.HandleFunc("POST /dashboards", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleCreate)) + mux.HandleFunc("GET /dashboards", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList)) + mux.HandleFunc("POST /dashboards/import", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleImport)) + mux.HandleFunc("GET /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGet)) + mux.HandleFunc("PUT /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdate)) + mux.HandleFunc("DELETE /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDelete)) + mux.HandleFunc("GET /dashboards/{id}/export", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleExport)) + mux.HandleFunc("POST /dashboards/{id}/panels", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleAddPanel)) + mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdatePanel)) + mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDeletePanel)) } const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi +// tenantID resolves the tenant to scope a request's store calls to -- +// from the authenticated identity RequireRole attached to the request +// context, never from a client-supplied field (a Dashboard JSON body +// can set "tenant_id" to anything; store.go's methods only ever see the +// value this function returns, not that field). Falls back to +// "default" when no identity is present (nil authorizer -- matches +// every other nil-authorizer-is-Phase-0-3-single-tenant default in this +// codebase) or when a resolved identity somehow carries no tenant (only +// RoleService identities can, per authz.Identity's doc comment, and +// RequireRole -- unlike RequireRoleOrService -- never admits RoleService, +// so this branch is a defensive fallback, not an expected path). +func (h *Handler) tenantID(r *http.Request) string { + if id, ok := authz.IdentityFromContext(r.Context()); ok && id.TenantID != "" { + return id.TenantID + } + return "default" +} + func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { var d Dashboard if !decodeJSON(w, r, &d) { @@ -56,6 +89,8 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "name must not be empty") return } + // Overrides any client-supplied tenant_id -- see tenantID's doc comment. + d.TenantID = h.tenantID(r) if err := h.store.CreateDashboard(r.Context(), &d); err != nil { h.logger.Error("creating dashboard", "error", err) writeError(w, http.StatusInternalServerError, "creating dashboard failed") @@ -65,7 +100,7 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) { - list, err := h.store.ListDashboards(r.Context()) + list, err := h.store.ListDashboards(r.Context(), h.tenantID(r)) if err != nil { h.logger.Error("listing dashboards", "error", err) writeError(w, http.StatusInternalServerError, "listing dashboards failed") @@ -75,7 +110,7 @@ func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { - d, err := h.store.GetDashboard(r.Context(), r.PathValue("id")) + d, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id")) if err != nil { h.writeStoreErr(w, err, "fetching dashboard") return @@ -100,7 +135,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "name must not be empty") return } - imported, err := h.store.ImportDashboard(r.Context(), &d) + imported, err := h.store.ImportDashboard(r.Context(), h.tenantID(r), &d) if err != nil { h.logger.Error("importing dashboard", "error", err) writeError(w, http.StatusBadRequest, err.Error()) @@ -119,7 +154,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { return } d.ID = r.PathValue("id") - if err := h.store.UpdateDashboard(r.Context(), &d); err != nil { + if err := h.store.UpdateDashboard(r.Context(), h.tenantID(r), &d); err != nil { h.writeStoreErr(w, err, "updating dashboard") return } @@ -127,7 +162,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) { - if err := h.store.DeleteDashboard(r.Context(), r.PathValue("id")); err != nil { + if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), r.PathValue("id")); err != nil { h.writeStoreErr(w, err, "deleting dashboard") return } @@ -143,9 +178,8 @@ func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - if err := h.store.AddPanel(r.Context(), r.PathValue("id"), &p); err != nil { - h.logger.Error("adding panel", "error", err) - writeError(w, http.StatusInternalServerError, "adding panel failed") + if err := h.store.AddPanel(r.Context(), h.tenantID(r), r.PathValue("id"), &p); err != nil { + h.writeStoreErr(w, err, "adding panel") return } writeJSON(w, http.StatusCreated, p) @@ -162,7 +196,7 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) { } p.ID = r.PathValue("panelId") p.DashboardID = r.PathValue("id") - if err := h.store.UpdatePanel(r.Context(), &p); err != nil { + if err := h.store.UpdatePanel(r.Context(), h.tenantID(r), &p); err != nil { h.writeStoreErr(w, err, "updating panel") return } @@ -170,7 +204,7 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) { - if err := h.store.DeletePanel(r.Context(), r.PathValue("id"), r.PathValue("panelId")); err != nil { + if err := h.store.DeletePanel(r.Context(), h.tenantID(r), r.PathValue("id"), r.PathValue("panelId")); err != nil { h.writeStoreErr(w, err, "deleting panel") return } diff --git a/api/internal/dashboards/handler_test.go b/api/internal/dashboards/handler_test.go index f374ffb..b7eb67b 100644 --- a/api/internal/dashboards/handler_test.go +++ b/api/internal/dashboards/handler_test.go @@ -10,8 +10,15 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/sentry/sentry/api/internal/authz" ) +// fakeStore enforces tenant scoping the same way store.go's real +// pgx-backed Store does (a mismatched tenantID behaves exactly like a +// missing ID -- ErrNotFound, never a distinguishable "found but wrong +// tenant" error) so handler_test.go's cross-tenant tests exercise real +// behavior, not a fake that happens to always agree. type fakeStore struct { dashboards map[string]*Dashboard createErr error @@ -31,44 +38,48 @@ func (f *fakeStore) CreateDashboard(_ context.Context, d *Dashboard) error { return nil } -func (f *fakeStore) ListDashboards(_ context.Context) ([]Dashboard, error) { +func (f *fakeStore) ListDashboards(_ context.Context, tenantID string) ([]Dashboard, error) { var out []Dashboard for _, d := range f.dashboards { - out = append(out, *d) + if d.TenantID == tenantID { + out = append(out, *d) + } } return out, nil } -func (f *fakeStore) GetDashboard(_ context.Context, id string) (*Dashboard, error) { +func (f *fakeStore) GetDashboard(_ context.Context, tenantID, id string) (*Dashboard, error) { d, ok := f.dashboards[id] - if !ok { + if !ok || d.TenantID != tenantID { return nil, ErrNotFound } return d, nil } -func (f *fakeStore) UpdateDashboard(_ context.Context, d *Dashboard) error { +func (f *fakeStore) UpdateDashboard(_ context.Context, tenantID string, d *Dashboard) error { existing, ok := f.dashboards[d.ID] - if !ok { + if !ok || existing.TenantID != tenantID { return ErrNotFound } panels := existing.Panels *existing = *d + existing.TenantID = tenantID existing.Panels = panels return nil } -func (f *fakeStore) DeleteDashboard(_ context.Context, id string) error { - if _, ok := f.dashboards[id]; !ok { +func (f *fakeStore) DeleteDashboard(_ context.Context, tenantID, id string) error { + d, ok := f.dashboards[id] + if !ok || d.TenantID != tenantID { return ErrNotFound } delete(f.dashboards, id) return nil } -func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) error { +func (f *fakeStore) AddPanel(_ context.Context, tenantID, dashboardID string, p *Panel) error { d, ok := f.dashboards[dashboardID] - if !ok { + if !ok || d.TenantID != tenantID { return ErrNotFound } p.ID = "panel-1" @@ -77,9 +88,9 @@ func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) er return nil } -func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error { +func (f *fakeStore) UpdatePanel(_ context.Context, tenantID string, p *Panel) error { d, ok := f.dashboards[p.DashboardID] - if !ok { + if !ok || d.TenantID != tenantID { return ErrNotFound } for i := range d.Panels { @@ -91,9 +102,9 @@ func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error { return ErrNotFound } -func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string) error { +func (f *fakeStore) DeletePanel(_ context.Context, tenantID, dashboardID, panelID string) error { d, ok := f.dashboards[dashboardID] - if !ok { + if !ok || d.TenantID != tenantID { return ErrNotFound } for i := range d.Panels { @@ -105,18 +116,39 @@ func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string) return ErrNotFound } -func (f *fakeStore) ImportDashboard(_ context.Context, d *Dashboard) (*Dashboard, error) { +func (f *fakeStore) ImportDashboard(_ context.Context, tenantID string, d *Dashboard) (*Dashboard, error) { if f.importErr != nil { return nil, f.importErr } imported := *d imported.ID = "dash-imported" + imported.TenantID = tenantID f.dashboards[imported.ID] = &imported return &imported, nil } func newTestMux(fs *fakeStore) *http.ServeMux { - h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs) + h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, nil) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + return mux +} + +// fakeAuthorizer resolves every request to a fixed identity -- used by +// the cross-tenant tests below, which need a real (non-nil) authorizer +// so tenantID(r) reads from the resolved identity instead of falling +// back to "default" for every request. +type fakeAuthorizer struct { + identity authz.Identity +} + +func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) { + return f.identity, nil +} + +func newTestMuxWithTenant(fs *fakeStore, tenantID string) *http.ServeMux { + h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, + &fakeAuthorizer{identity: authz.Identity{TenantID: tenantID, Role: authz.RoleOwner}}) mux := http.NewServeMux() h.RegisterRoutes(mux) return mux @@ -149,6 +181,25 @@ func TestCreateDashboard(t *testing.T) { } } +// TestCreateDashboardIgnoresClientSuppliedTenantID is the regression +// test for the tenant-spoofing gap found during Phase 4 task 7 (see +// /docs/security/threat-model.md's "application-layer tenant scoping" +// section): Dashboard.TenantID has a `json:"tenant_id"` tag, so a +// request body can set it to anything. The handler must always +// overwrite it from the authenticated identity, never trust the body. +func TestCreateDashboardIgnoresClientSuppliedTenantID(t *testing.T) { + fs := newFakeStore() + mux := newTestMuxWithTenant(fs, "acme") + + rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview", "tenant_id": "globex"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + if fs.dashboards["dash-1"].TenantID != "acme" { + t.Fatalf("stored TenantID = %q, want %q (the authenticated identity's tenant, not the client-supplied value)", fs.dashboards["dash-1"].TenantID, "acme") + } +} + func TestCreateDashboardRejectsEmptyName(t *testing.T) { mux := newTestMux(newFakeStore()) rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": ""}`) @@ -165,9 +216,87 @@ func TestGetDashboardNotFound(t *testing.T) { } } +// TestCrossTenantGetIsNotFound is the core adversarial case: a request +// authenticated as tenant "globex" must not be able to read a dashboard +// that belongs to tenant "acme" -- and the response must be a plain 404 +// (not a 403, which would confirm the ID exists under a different +// tenant). +func TestCrossTenantGetIsNotFound(t *testing.T) { + fs := newFakeStore() + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"} + mux := newTestMuxWithTenant(fs, "globex") + + rec := doRequest(t, mux, http.MethodGet, "/dashboards/dash-1", "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (cross-tenant read must not succeed or leak existence via a different status)", rec.Code) + } +} + +func TestCrossTenantListDoesNotLeakOtherTenants(t *testing.T) { + fs := newFakeStore() + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"} + fs.dashboards["dash-2"] = &Dashboard{ID: "dash-2", TenantID: "globex", Name: "Globex's dashboard"} + mux := newTestMuxWithTenant(fs, "globex") + + rec := doRequest(t, mux, http.MethodGet, "/dashboards", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got []Dashboard + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(got) != 1 || got[0].ID != "dash-2" { + t.Fatalf("expected only globex's own dashboard, got %+v", got) + } +} + +func TestCrossTenantUpdateIsNotFound(t *testing.T) { + fs := newFakeStore() + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"} + mux := newTestMuxWithTenant(fs, "globex") + + rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Hijacked"}`) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if fs.dashboards["dash-1"].Name != "Acme's dashboard" { + t.Fatalf("cross-tenant update must not modify the row, got Name = %q", fs.dashboards["dash-1"].Name) + } +} + +func TestCrossTenantDeleteIsNotFound(t *testing.T) { + fs := newFakeStore() + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"} + mux := newTestMuxWithTenant(fs, "globex") + + rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if _, ok := fs.dashboards["dash-1"]; !ok { + t.Fatalf("cross-tenant delete must not remove the row") + } +} + +func TestCrossTenantAddPanelIsNotFound(t *testing.T) { + fs := newFakeStore() + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"} + mux := newTestMuxWithTenant(fs, "globex") + + rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels", + `{"query": "service=api", "viz_type": "table"}`) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } + if len(fs.dashboards["dash-1"].Panels) != 0 { + t.Fatalf("cross-tenant AddPanel must not attach a panel to the other tenant's dashboard") + } +} + func TestAddPanelRejectsRawSQL(t *testing.T) { fs := newFakeStore() - fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"} + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"} mux := newTestMux(fs) rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels", @@ -179,7 +308,7 @@ func TestAddPanelRejectsRawSQL(t *testing.T) { func TestAddPanelRejectsInvalidVizType(t *testing.T) { fs := newFakeStore() - fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"} + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"} mux := newTestMux(fs) rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels", @@ -191,7 +320,7 @@ func TestAddPanelRejectsInvalidVizType(t *testing.T) { func TestAddPanelSuccess(t *testing.T) { fs := newFakeStore() - fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"} + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"} mux := newTestMux(fs) rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels", @@ -206,7 +335,7 @@ func TestAddPanelSuccess(t *testing.T) { func TestUpdateDashboardChangesTimeRange(t *testing.T) { fs := newFakeStore() - fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"} + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"} mux := newTestMux(fs) rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", @@ -229,7 +358,7 @@ func TestUpdateDashboardNotFound(t *testing.T) { func TestDeleteDashboard(t *testing.T) { fs := newFakeStore() - fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"} + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"} mux := newTestMux(fs) rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "") @@ -244,7 +373,7 @@ func TestDeleteDashboard(t *testing.T) { func TestExportThenImportRoundTrips(t *testing.T) { fs := newFakeStore() fs.dashboards["dash-1"] = &Dashboard{ - ID: "dash-1", Name: "Overview", + ID: "dash-1", TenantID: "default", Name: "Overview", Panels: []Panel{{ID: "panel-1", DashboardID: "dash-1", Query: "service=api", VizType: VizTable}}, } mux := newTestMux(fs) @@ -267,6 +396,28 @@ func TestExportThenImportRoundTrips(t *testing.T) { } } +// TestImportIgnoresExportedTenantID: an exported dashboard JSON file +// carries whatever tenant_id it was exported from -- importing it into +// a different tenant's session must assign it to the *importing* +// tenant, never silently move it to the tenant named in the file. +func TestImportIgnoresExportedTenantID(t *testing.T) { + fs := newFakeStore() + mux := newTestMuxWithTenant(fs, "globex") + + rec := doRequest(t, mux, http.MethodPost, "/dashboards/import", + `{"name": "Imported", "tenant_id": "acme"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + var imported Dashboard + if err := json.Unmarshal(rec.Body.Bytes(), &imported); err != nil { + t.Fatalf("decoding response: %v", err) + } + if imported.TenantID != "globex" { + t.Fatalf("imported TenantID = %q, want %q (the importing identity's tenant)", imported.TenantID, "globex") + } +} + func TestCreateDashboardStoreErrorReturns500(t *testing.T) { fs := newFakeStore() fs.createErr = errors.New("boom") @@ -277,3 +428,37 @@ func TestCreateDashboardStoreErrorReturns500(t *testing.T) { t.Fatalf("status = %d, want 500", rec.Code) } } + +// TestServiceIdentityCannotAccessDashboards is the other half of the +// service-identity boundary (the /query half is +// api/internal/queryapi's own tests) -- api/internal/authz's own tests +// already prove RequireRole rejects RoleService in isolation +// (TestRequireRolePlainDoesNotAllowService); this proves it holds +// through the real dashboards handler, wired the way it's actually +// deployed, not just the middleware function in isolation. A defect +// here would mean /alerting's service token -- meant only for POST +// /query -- could also read/write dashboards, which was never the +// intent (dashboards uses plain RequireRole, not RequireRoleOrService, +// specifically to keep this door shut). +func TestServiceIdentityCannotAccessDashboards(t *testing.T) { + fs := newFakeStore() + fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"} + h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, + &fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}}) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + for _, tt := range []struct { + method, path, body string + }{ + {http.MethodGet, "/dashboards", ""}, + {http.MethodGet, "/dashboards/dash-1", ""}, + {http.MethodPost, "/dashboards", `{"name": "New"}`}, + {http.MethodDelete, "/dashboards/dash-1", ""}, + } { + rec := doRequest(t, mux, tt.method, tt.path, tt.body) + if rec.Code != http.StatusForbidden { + t.Errorf("%s %s: status = %d, want 403 (RoleService must never access dashboards)", tt.method, tt.path, rec.Code) + } + } +} diff --git a/api/internal/dashboards/store.go b/api/internal/dashboards/store.go index e232b8c..331249b 100644 --- a/api/internal/dashboards/store.go +++ b/api/internal/dashboards/store.go @@ -10,13 +10,30 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// ErrNotFound is returned by Get/Delete when the id doesn't exist. +// ErrNotFound is returned by Get/Delete when the id doesn't exist -- +// including when it exists but belongs to a different tenant (see this +// file's tenant-scoping comment below): a 404 either way, never a 403 +// that would confirm cross-tenant existence. var ErrNotFound = errors.New("not found") // Store is the pgx-backed CRUD implementation. IDs are assigned // server-side (google/uuid), matching how /ingest assigns record_id -- // one place (Go) generates IDs, not split between the app and the // database via a Postgres extension. +// +// Every method below except CreateDashboard/ImportDashboard takes a +// tenantID and filters by it (`WHERE ... AND tenant_id = $N`, or a join +// through dashboards for the panel methods, since dashboard_panels has +// no tenant_id column of its own). This is Phase 4 task 5/8 tenant +// scoping, added after the authz RBAC wiring shipped without it -- see +// /docs/security/threat-model.md's "application-layer tenant scoping" +// section for why that gap mattered even with RBAC live: a role check +// alone answers "is this identity allowed to edit *some* dashboard," +// not "is this identity allowed to touch *this* dashboard." The +// handler (handler.go) resolves tenantID from the authenticated +// identity (authz.IdentityFromContext) -- never from a client-supplied +// request field, since Dashboard.TenantID is a JSON field a request +// body can set arbitrarily. type Store struct { pool *pgxpool.Pool } @@ -25,6 +42,10 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } +// CreateDashboard trusts d.TenantID -- callers (handler.go) must set it +// from the authenticated identity before calling, never from client +// input. Not itself tenant-scoped (there's nothing to scope against +// yet; the row doesn't exist). func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error { d.ID = uuid.NewString() if d.TenantID == "" { @@ -47,10 +68,10 @@ func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error { return row.Scan(&d.CreatedAt, &d.UpdatedAt) } -func (s *Store) ListDashboards(ctx context.Context) ([]Dashboard, error) { +func (s *Store) ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error) { rows, err := s.pool.Query(ctx, ` SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at - FROM dashboards ORDER BY created_at DESC`) + FROM dashboards WHERE tenant_id = $1 ORDER BY created_at DESC`, tenantID) if err != nil { return nil, err } @@ -67,11 +88,11 @@ func (s *Store) ListDashboards(ctx context.Context) ([]Dashboard, error) { return out, rows.Err() } -func (s *Store) GetDashboard(ctx context.Context, id string) (*Dashboard, error) { +func (s *Store) GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error) { var d Dashboard row := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at - FROM dashboards WHERE id = $1`, id) + FROM dashboards WHERE id = $1 AND tenant_id = $2`, id, tenantID) if err := row.Scan(&d.ID, &d.TenantID, &d.Name, &d.Description, &d.DefaultEarliest, &d.DefaultLatest, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -87,6 +108,10 @@ func (s *Store) GetDashboard(ctx context.Context, id string) (*Dashboard, error) return &d, nil } +// listPanels doesn't itself take a tenantID -- every call site first +// resolves the owning dashboard via a tenant-scoped query (GetDashboard +// above, or dashboardTenantMatches below), so by the time this runs, +// dashboardID is already known to belong to the caller's tenant. func (s *Store) listPanels(ctx context.Context, dashboardID string) ([]Panel, error) { rows, err := s.pool.Query(ctx, ` SELECT id, dashboard_id, title, query, query_language, viz_type, viz_config, @@ -111,7 +136,21 @@ func (s *Store) listPanels(ctx context.Context, dashboardID string) ([]Panel, er return out, rows.Err() } -func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error { +// dashboardTenantMatches is the join every panel-mutating method below +// uses in place of a tenant_id column dashboard_panels doesn't have -- +// "does this dashboard exist AND belong to this tenant." A plain +// EXISTS query, not a full row fetch: the panel methods that call this +// only need a yes/no gate, not the dashboard's data. +func (s *Store) dashboardTenantMatches(ctx context.Context, tenantID, dashboardID string) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM dashboards WHERE id = $1 AND tenant_id = $2)`, + dashboardID, tenantID, + ).Scan(&exists) + return exists, err +} + +func (s *Store) UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error { if d.DefaultEarliest == "" { d.DefaultEarliest = "-1h" } @@ -120,9 +159,9 @@ func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error { } row := s.pool.QueryRow(ctx, ` UPDATE dashboards SET name = $1, description = $2, default_earliest = $3, default_latest = $4, updated_at = now() - WHERE id = $5 + WHERE id = $5 AND tenant_id = $6 RETURNING tenant_id, created_by, created_at, updated_at`, - d.Name, d.Description, d.DefaultEarliest, d.DefaultLatest, d.ID) + d.Name, d.Description, d.DefaultEarliest, d.DefaultLatest, d.ID, tenantID) if err := row.Scan(&d.TenantID, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound @@ -132,8 +171,8 @@ func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error { return nil } -func (s *Store) DeleteDashboard(ctx context.Context, id string) error { - tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1`, id) +func (s *Store) DeleteDashboard(ctx context.Context, tenantID, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1 AND tenant_id = $2`, id, tenantID) if err != nil { return err } @@ -143,7 +182,14 @@ func (s *Store) DeleteDashboard(ctx context.Context, id string) error { return nil } -func (s *Store) AddPanel(ctx context.Context, dashboardID string, p *Panel) error { +func (s *Store) AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error { + ok, err := s.dashboardTenantMatches(ctx, tenantID, dashboardID) + if err != nil { + return err + } + if !ok { + return ErrNotFound + } p.ID = uuid.NewString() p.DashboardID = dashboardID row := s.pool.QueryRow(ctx, ` @@ -156,7 +202,14 @@ func (s *Store) AddPanel(ctx context.Context, dashboardID string, p *Panel) erro return row.Scan(&p.CreatedAt, &p.UpdatedAt) } -func (s *Store) UpdatePanel(ctx context.Context, p *Panel) error { +func (s *Store) UpdatePanel(ctx context.Context, tenantID string, p *Panel) error { + ok, err := s.dashboardTenantMatches(ctx, tenantID, p.DashboardID) + if err != nil { + return err + } + if !ok { + return ErrNotFound + } tag, err := s.pool.Exec(ctx, ` UPDATE dashboard_panels SET title = $1, query = $2, query_language = $3, viz_type = $4, viz_config = $5, @@ -175,7 +228,14 @@ func (s *Store) UpdatePanel(ctx context.Context, p *Panel) error { return nil } -func (s *Store) DeletePanel(ctx context.Context, dashboardID, panelID string) error { +func (s *Store) DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error { + ok, err := s.dashboardTenantMatches(ctx, tenantID, dashboardID) + if err != nil { + return err + } + if !ok { + return ErrNotFound + } tag, err := s.pool.Exec(ctx, `DELETE FROM dashboard_panels WHERE id = $1 AND dashboard_id = $2`, panelID, dashboardID) if err != nil { return err @@ -191,8 +251,12 @@ func (s *Store) DeletePanel(ctx context.Context, dashboardID, panelID string) er // importing an exported dashboard into a different environment (or // re-importing into the same one) never collides with the source IDs. // Runs in one transaction: either the whole dashboard lands, or none of -// it does. -func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error) { +// it does. tenantID comes from the caller (the authenticated identity), +// never from d.TenantID -- an exported dashboard JSON file carries +// whatever tenant_id it was exported from, and importing it must not +// let that value silently re-assign the dashboard to a different +// tenant than the importing user's own. +func (s *Store) ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err @@ -200,10 +264,6 @@ func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, defer tx.Rollback(ctx) id := uuid.NewString() - tenantID := d.TenantID - if tenantID == "" { - tenantID = "default" - } createdBy := d.CreatedBy if createdBy == "" { createdBy = "anonymous" diff --git a/api/internal/dashboards/store_integration_test.go b/api/internal/dashboards/store_integration_test.go new file mode 100644 index 0000000..44d551e --- /dev/null +++ b/api/internal/dashboards/store_integration_test.go @@ -0,0 +1,192 @@ +// Adversarial cross-tenant isolation tests against a real Postgres -- +// Phase 4 task 8. handler_test.go's TestCrossTenant* tests already cover +// this against fakeStore (which is hand-written to mimic the real SQL's +// tenant filtering); these tests exercise the actual parameterized SQL +// in store.go, including the tenant_id foreign key constraint added in +// metadata/migrations/0027_add_dashboards_tenant_fk.sql -- a real gap a +// fake store literally cannot catch (e.g. a typo in a WHERE clause, or +// forgetting to update every method when the schema changes). +// +// Skipped unless DASHBOARDS_TEST_POSTGRES_ADDR is set; run via: +// +// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \ +// -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ +// -e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \ +// golang:1.25-alpine go test ./internal/dashboards/... -run Integration -v +package dashboards + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func integrationStore(t *testing.T) (*Store, *pgxpool.Pool) { + t.Helper() + addr := os.Getenv("DASHBOARDS_TEST_POSTGRES_ADDR") + if addr == "" { + t.Skip("DASHBOARDS_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") + } + password := os.Getenv("DASHBOARDS_TEST_POSTGRES_PASSWORD") + dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("opening pool: %v", err) + } + t.Cleanup(pool.Close) + return NewStore(pool), pool +} + +// createTestTenant inserts directly into the tenants table (owned by +// metadata/migrations/0017-0019, not this package) -- dashboards.tenant_id +// has had a real foreign-key constraint since +// metadata/migrations/0027_add_dashboards_tenant_fk.sql, so a dashboard +// row for a tenant that doesn't exist in `tenants` is rejected by +// Postgres itself, not just application logic. Uses a unique suffix so +// repeated test runs against a persistent dev Postgres don't collide. +func createTestTenant(t *testing.T, pool *pgxpool.Pool) string { + t.Helper() + id := "test-tenant-" + uuid.NewString()[:8] + _, err := pool.Exec(context.Background(), + `INSERT INTO tenants (id, display_name, status) VALUES ($1, $1, 'active')`, id) + if err != nil { + t.Fatalf("creating test tenant: %v", err) + } + return id +} + +func TestIntegrationCrossTenantGetIsNotFound(t *testing.T) { + store, pool := integrationStore(t) + ctx := context.Background() + tenantA := createTestTenant(t, pool) + tenantB := createTestTenant(t, pool) + + d := &Dashboard{TenantID: tenantA, Name: "Acme's dashboard"} + if err := store.CreateDashboard(ctx, d); err != nil { + t.Fatalf("CreateDashboard: %v", err) + } + + // Same tenant: found. + if _, err := store.GetDashboard(ctx, tenantA, d.ID); err != nil { + t.Fatalf("GetDashboard (same tenant): %v", err) + } + // Different tenant: not found, not a data leak. + if _, err := store.GetDashboard(ctx, tenantB, d.ID); err != ErrNotFound { + t.Fatalf("GetDashboard (cross-tenant) error = %v, want ErrNotFound", err) + } +} + +func TestIntegrationCrossTenantListDoesNotLeak(t *testing.T) { + store, pool := integrationStore(t) + ctx := context.Background() + tenantA := createTestTenant(t, pool) + tenantB := createTestTenant(t, pool) + + da := &Dashboard{TenantID: tenantA, Name: "A's dashboard"} + db := &Dashboard{TenantID: tenantB, Name: "B's dashboard"} + if err := store.CreateDashboard(ctx, da); err != nil { + t.Fatalf("CreateDashboard A: %v", err) + } + if err := store.CreateDashboard(ctx, db); err != nil { + t.Fatalf("CreateDashboard B: %v", err) + } + + listA, err := store.ListDashboards(ctx, tenantA) + if err != nil { + t.Fatalf("ListDashboards A: %v", err) + } + for _, d := range listA { + if d.TenantID != tenantA { + t.Fatalf("tenant A's list leaked a dashboard from tenant %q", d.TenantID) + } + } + found := false + for _, d := range listA { + if d.ID == da.ID { + found = true + } + if d.ID == db.ID { + t.Fatalf("tenant A's list included tenant B's dashboard %q", db.ID) + } + } + if !found { + t.Fatal("tenant A's list did not include tenant A's own dashboard") + } +} + +func TestIntegrationCrossTenantUpdateAndDeleteAreNotFound(t *testing.T) { + store, pool := integrationStore(t) + ctx := context.Background() + tenantA := createTestTenant(t, pool) + tenantB := createTestTenant(t, pool) + + d := &Dashboard{TenantID: tenantA, Name: "Original"} + if err := store.CreateDashboard(ctx, d); err != nil { + t.Fatalf("CreateDashboard: %v", err) + } + + hijack := &Dashboard{ID: d.ID, Name: "Hijacked"} + if err := store.UpdateDashboard(ctx, tenantB, hijack); err != ErrNotFound { + t.Fatalf("cross-tenant UpdateDashboard error = %v, want ErrNotFound", err) + } + got, err := store.GetDashboard(ctx, tenantA, d.ID) + if err != nil { + t.Fatalf("GetDashboard after attempted cross-tenant update: %v", err) + } + if got.Name != "Original" { + t.Fatalf("cross-tenant update mutated the row: Name = %q", got.Name) + } + + if err := store.DeleteDashboard(ctx, tenantB, d.ID); err != ErrNotFound { + t.Fatalf("cross-tenant DeleteDashboard error = %v, want ErrNotFound", err) + } + if _, err := store.GetDashboard(ctx, tenantA, d.ID); err != nil { + t.Fatalf("expected the dashboard to still exist after a failed cross-tenant delete: %v", err) + } +} + +func TestIntegrationCrossTenantPanelMutationIsNotFound(t *testing.T) { + store, pool := integrationStore(t) + ctx := context.Background() + tenantA := createTestTenant(t, pool) + tenantB := createTestTenant(t, pool) + + d := &Dashboard{TenantID: tenantA, Name: "Has panels"} + if err := store.CreateDashboard(ctx, d); err != nil { + t.Fatalf("CreateDashboard: %v", err) + } + + p := &Panel{Query: "service=api", VizType: VizTable, Width: 6, Height: 4} + if err := store.AddPanel(ctx, tenantB, d.ID, p); err != ErrNotFound { + t.Fatalf("cross-tenant AddPanel error = %v, want ErrNotFound", err) + } + + // Add it for real (tenant A), then confirm tenant B can't update/delete it either. + if err := store.AddPanel(ctx, tenantA, d.ID, p); err != nil { + t.Fatalf("AddPanel (same tenant): %v", err) + } + p.Title = "Hijacked" + if err := store.UpdatePanel(ctx, tenantB, p); err != ErrNotFound { + t.Fatalf("cross-tenant UpdatePanel error = %v, want ErrNotFound", err) + } + if err := store.DeletePanel(ctx, tenantB, d.ID, p.ID); err != ErrNotFound { + t.Fatalf("cross-tenant DeletePanel error = %v, want ErrNotFound", err) + } +} + +// TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant proves +// the database itself, not just application code, refuses a dashboard +// for a tenant that was never provisioned -- defense in depth +// independent of the Go-level tenant scoping above (see +// metadata/migrations/0027_add_dashboards_tenant_fk.sql). +func TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant(t *testing.T) { + store, _ := integrationStore(t) + d := &Dashboard{TenantID: "does-not-exist-" + uuid.NewString()[:8], Name: "Orphan"} + if err := store.CreateDashboard(context.Background(), d); err == nil { + t.Fatal("expected CreateDashboard to fail for a tenant_id with no matching tenants row") + } +} diff --git a/api/internal/queryapi/handler.go b/api/internal/queryapi/handler.go index a1dfba2..cab73af 100644 --- a/api/internal/queryapi/handler.go +++ b/api/internal/queryapi/handler.go @@ -19,19 +19,58 @@ import ( "strings" "time" + "github.com/sentry/sentry/api/internal/authz" "github.com/sentry/sentry/api/internal/querylang/executor" "github.com/sentry/sentry/api/internal/querylang/planner" ) +// AuditLogger is core's extension point for query audit logging -- +// deliberately minimal and tenant-agnostic, since core has no concept of +// tenants (see /docs/phase-4-isolation-design.md: that mechanism lives +// entirely in enterprise/). enterprise/internal/audit implements this +// against the real hash-chained, append-only store; a nil AuditLogger +// (the default for a single-tenant deployment without enterprise/ +// configured) means no audit logging happens and core behaves exactly +// as it did in Phases 0-3. +// +// Tenant/user identity is deliberately NOT a field on QueryAuditEntry -- +// once Phase 4 task 5's auth middleware wraps this handler, it attaches +// that identity to the request's context.Context via +// enterprise/internal/tenant, and LogQuery's ctx parameter is the same +// context the request carried, so an enterprise-side implementation +// reads identity from ctx rather than this interface growing +// tenant-awareness. Per /docs/phase-4-isolation-design.md's audit +// section, this is a fail-open path: a LogQuery error is logged but +// never fails the HTTP response for a routine read query. +type AuditLogger interface { + LogQuery(ctx context.Context, entry QueryAuditEntry) error +} + +type QueryAuditEntry struct { + Query string + Language string + RowCount int + Duration time.Duration + Success bool + Error string +} + type Handler struct { logger *slog.Logger sqlRunner executor.SQLRunner search executor.SearchClient queryTimeout time.Duration + audit AuditLogger + authorizer authz.Authorizer } -func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration) *Handler { - return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout} +// audit and authorizer may both be nil -- see AuditLogger's doc comment +// and authz.RequireRoleOrService's nil-safety. /query allows RoleViewer +// (human sessions) or the alerting service identity (RoleService) -- +// it's the one endpoint /alerting's evaluator legitimately calls, per +// /docs/phase-4-isolation-design.md's alerting service-identity design. +func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration, audit AuditLogger, authorizer authz.Authorizer) *Handler { + return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout, audit: audit, authorizer: authorizer} } // RegisterRoutes adds this handler's routes onto a shared mux. Phase 3 @@ -40,7 +79,7 @@ func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search execut // than by each handler wrapping itself individually -- see // httpserver.WithCORS. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /query", h.handleQuery) + mux.HandleFunc("POST /query", authz.RequireRoleOrService(h.authorizer, authz.RoleViewer, h.handleQuery)) mux.HandleFunc("GET /healthz", h.handleHealthz) } @@ -98,16 +137,43 @@ func (h *Handler) handleQuery(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), h.queryTimeout) defer cancel() + start := time.Now() result, err := executor.Execute(ctx, plan, h.sqlRunner, h.search) + duration := time.Since(start) + if err != nil { h.logger.Error("query execution failed", "query", req.Query, "error", err) + h.logAudit(r.Context(), req, 0, duration, err) writeError(w, http.StatusBadGateway, "query failed: "+err.Error()) return } + h.logAudit(r.Context(), req, len(result.Rows), duration, nil) writeJSON(w, queryResponse{Columns: result.Columns, Rows: result.Rows}) } +// logAudit is fail-open by design (see AuditLogger's doc comment): a +// write failure here is logged and otherwise ignored, never surfaced to +// the HTTP caller. Uses r.Context() (the original request context, not +// the query-execution one with its own deadline) so a slow/cancelled +// query's context.WithTimeout expiring doesn't also cancel the audit +// write for it. +func (h *Handler) logAudit(ctx context.Context, req queryRequest, rowCount int, duration time.Duration, execErr error) { + if h.audit == nil { + return + } + entry := QueryAuditEntry{ + Query: req.Query, Language: req.Language, RowCount: rowCount, + Duration: duration, Success: execErr == nil, + } + if execErr != nil { + entry.Error = execErr.Error() + } + if err := h.audit.LogQuery(ctx, entry); err != nil { + h.logger.Error("audit log write failed", "error", err) + } +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) diff --git a/api/internal/queryapi/handler_test.go b/api/internal/queryapi/handler_test.go index f4434a5..dbd2a2d 100644 --- a/api/internal/queryapi/handler_test.go +++ b/api/internal/queryapi/handler_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/sentry/sentry/api/internal/authz" "github.com/sentry/sentry/api/internal/querylang/executor" ) @@ -50,7 +51,21 @@ func newTestHandler(sqlRunner *fakeSQLRunner, search *fakeSearchClient) *Handler if search == nil { search = &fakeSearchClient{} } - return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second) + return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second, nil, nil) +} + +type fakeAuditLogger struct { + entries []QueryAuditEntry + err error +} + +func (f *fakeAuditLogger) LogQuery(_ context.Context, entry QueryAuditEntry) error { + f.entries = append(f.entries, entry) + return f.err +} + +func newTestHandlerWithAudit(sqlRunner *fakeSQLRunner, audit *fakeAuditLogger) *Handler { + return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, &fakeSearchClient{}, time.Second, audit, nil) } func newTestMux(h *Handler) *http.ServeMux { @@ -207,3 +222,121 @@ func TestHandleHealthz(t *testing.T) { t.Fatalf("status = %d, want 200", rec.Code) } } + +func TestHandleQueryLogsAuditEntryOnSuccess(t *testing.T) { + sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{"host"}, Rows: [][]any{{"h1"}, {"h2"}}}} + audit := &fakeAuditLogger{} + h := newTestHandlerWithAudit(sr, audit) + + rec := postQuery(t, h, `{"query": "SELECT host FROM logs"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + if len(audit.entries) != 1 { + t.Fatalf("expected 1 audit entry, got %d", len(audit.entries)) + } + entry := audit.entries[0] + if entry.Query != "SELECT host FROM logs" || !entry.Success || entry.RowCount != 2 || entry.Error != "" { + t.Fatalf("unexpected audit entry: %+v", entry) + } +} + +func TestHandleQueryLogsAuditEntryOnFailure(t *testing.T) { + sr := &fakeSQLRunner{err: errors.New("boom")} + audit := &fakeAuditLogger{} + h := newTestHandlerWithAudit(sr, audit) + + rec := postQuery(t, h, `{"query": "SELECT 1"}`) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } + + if len(audit.entries) != 1 { + t.Fatalf("expected 1 audit entry even on failure, got %d", len(audit.entries)) + } + entry := audit.entries[0] + if entry.Success || entry.Error == "" { + t.Fatalf("expected a failed audit entry with an error message, got %+v", entry) + } +} + +// TestHandleQueryAuditWriteFailureDoesNotFailRequest proves the +// fail-open design: a request still succeeds even when the audit +// logger itself errors -- per queryapi.AuditLogger's doc comment and +// /docs/phase-4-isolation-design.md's audit fail-open/fail-closed policy. +func TestHandleQueryAuditWriteFailureDoesNotFailRequest(t *testing.T) { + sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}} + audit := &fakeAuditLogger{err: errors.New("audit backend unreachable")} + h := newTestHandlerWithAudit(sr, audit) + + rec := postQuery(t, h, `{"query": "SELECT 1"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 -- an audit write failure must not fail the request", rec.Code) + } +} + +func TestHandleQueryNilAuditLoggerIsNoOp(t *testing.T) { + sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}} + h := newTestHandler(sr, nil) // audit is nil here + + rec := postQuery(t, h, `{"query": "SELECT 1"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +// fakeAuthorizer resolves every request to a fixed identity/error -- +// task 5 wired authz.RequireRoleOrService into RegisterRoutes but every +// existing test above passes a nil authorizer (a deliberate no-op), so +// none of them actually exercise the wiring with a real authorizer +// present. Phase 4 task 8 (adversarial tests) closes that gap: these +// prove /query's authz boundary holds when a real Authorizer is wired +// in, not just that the middleware function works in isolation +// (authz/middleware_test.go already covers that). +type fakeAuthorizer struct { + identity authz.Identity + err error +} + +func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) { + return f.identity, f.err +} + +func newTestHandlerWithAuthorizer(sqlRunner *fakeSQLRunner, authorizer authz.Authorizer) *Handler { + return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, &fakeSearchClient{}, time.Second, nil, authorizer) +} + +func TestHandleQueryRejectsUnauthenticatedWhenAuthorizerConfigured(t *testing.T) { + sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}} + h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{err: errors.New("no session")}) + + rec := postQuery(t, h, `{"query": "SELECT 1"}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestHandleQueryAllowsViewer(t *testing.T) { + sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}} + h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{identity: authz.Identity{TenantID: "acme", Role: authz.RoleViewer}}) + + rec := postQuery(t, h, `{"query": "SELECT 1"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } +} + +// TestHandleQueryAllowsServiceIdentity is the other half of the +// alerting<->api gap's fix (/docs/phase-4-isolation-design.md) -- +// /alerting's evaluator must be able to call POST /query with its +// RoleService credential even though it's not a human session. +func TestHandleQueryAllowsServiceIdentity(t *testing.T) { + sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}} + h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}}) + + rec := postQuery(t, h, `{"query": "SELECT 1"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 -- RoleService must be allowed on /query; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/api/internal/queryapi/tenant_isolation_gap_test.go b/api/internal/queryapi/tenant_isolation_gap_test.go new file mode 100644 index 0000000..2c9e214 --- /dev/null +++ b/api/internal/queryapi/tenant_isolation_gap_test.go @@ -0,0 +1,63 @@ +// This file is a checklist, not a passing test suite -- it exists so +// the four adversarial probes /docs/phase-4-isolation-design.md's +// "Verification plan for this design specifically" section names for +// Phase 4 task 8 have a permanent, grep-able home in the test tree, +// even though none of them can run for real yet. +// +// Why they can't run: every one of these probes needs a *per-tenant* +// ClickHouse user/database or Tantivy index to attack -- and none +// exist. api/internal/querylang/executor.SQLRunner/SearchClient (the +// only two interfaces api/internal/queryapi.Handler talks to) carry no +// tenant field at all, confirmed by reading both interfaces; neither +// does proto/sentry/search/v1/search.proto's SearchRequest. See +// /docs/security/threat-model.md's "Read this first" section for the +// full writeup -- there is currently exactly one shared ClickHouse +// connection and one shared Tantivy index for every tenant, so "does +// tenant A's connection leak tenant B's data" has no meaningful +// operational answer yet: there's only one connection. +// +// Each Skip below names precisely what has to exist before that test +// can be written for real (enterprise/internal/tenantprovision, +// enterprise/internal/chrunner, enterprise/internal/searchclient -- all +// still unbuilt, per the Phase 4 task 5 summary). Turning a Skip here +// into a real assertion is the acceptance criterion for those packages, +// not a nice-to-have follow-up. +package queryapi + +import "testing" + +func TestAdversarial_ClickHouseUserCannotReadOtherTenantDatabaseByFullyQualifiedName(t *testing.T) { + t.Skip("BLOCKED on enterprise/internal/tenantprovision + enterprise/internal/chrunner: " + + "needs two real per-tenant ClickHouse users/databases to attempt " + + "`SELECT * FROM other_tenant_db.logs` against. See " + + "/docs/phase-4-isolation-design.md's verification plan, item 1.") +} + +func TestAdversarial_ClickHouseUserCannotReadSystemTables(t *testing.T) { + t.Skip("BLOCKED on enterprise/internal/tenantprovision: needs a real per-tenant " + + "ClickHouse user to attempt `SELECT * FROM system.query_log`, " + + "`system.tables`, `SHOW DATABASES` against, and confirm system.* " + + "access was actually revoked (not just assumed from ClickHouse's " + + "default template -- task 2's finding was that this is " + + "version-dependent and must be checked live, not read from docs). " + + "See /docs/phase-4-isolation-design.md's verification plan, item 2.") +} + +func TestAdversarial_TantivySearchExcludesOtherTenantsMatchingResults(t *testing.T) { + t.Skip("BLOCKED on enterprise/internal/searchclient: needs two real " + + "per-tenant Tantivy indices, one seeded with a term, to confirm a " + + "search scoped to the other tenant returns zero hits for that term " + + "even though the term exists in the other index. See " + + "/docs/phase-4-isolation-design.md's verification plan, item 3.") +} + +func TestAdversarial_EvaluatorTickMidProvisioningIsRefusedNotServed(t *testing.T) { + t.Skip("BLOCKED on enterprise/internal/tenantprovision's ordered " + + "provisioning state machine (CREATE USER -> GRANT -> mark active): " + + "needs a tenant row that exists but hasn't reached the active gate " + + "yet, and a simulated /alerting evaluator tick against it, to " + + "confirm every tenant-resolution path actually checks tenant " + + "status server-side rather than inferring readiness from ambient " + + "connection success. See /docs/phase-4-isolation-design.md's " + + "verification plan, item 4, and its provisioning-gate requirement.") +} diff --git a/cli/cmd/sentryctl/auth_test.go b/cli/cmd/sentryctl/auth_test.go new file mode 100644 index 0000000..847d325 --- /dev/null +++ b/cli/cmd/sentryctl/auth_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestResolveTokenFromEnv(t *testing.T) { + env := func(k string) string { + if k == "SENTRYCTL_TOKEN" { + return "secret-token" + } + return "" + } + if got := resolveToken(env); got != "secret-token" { + t.Errorf("got %q, want %q", got, "secret-token") + } +} + +func TestHTTPGetJSONForwardsBearerToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := httpGetJSON(srv.URL, "/thing", "my-token", &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + if gotAuth != "Bearer my-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer my-token") + } +} + +func TestHTTPGetJSONOmitsAuthorizationWhenNoToken(t *testing.T) { + sawHeader := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawHeader = r.Header.Get("Authorization") != "" + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := httpGetJSON(srv.URL, "/thing", "", &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + if sawHeader { + t.Fatalf("expected no Authorization header when no token is configured") + } +} + +func TestHTTPPostFileJSONForwardsBearerToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "payload-*.json") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + if _, err := f.WriteString(`{"name":"test"}`); err != nil { + t.Fatalf("writing temp file: %v", err) + } + f.Close() + + var stdout, stderr bytes.Buffer + code := httpPostFileJSON(srv.URL, "/thing", "my-token", f.Name(), &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + if gotAuth != "Bearer my-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer my-token") + } +} + +func TestCmdPingForwardsBearerToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + t.Setenv("SENTRYCTL_TOKEN", "ping-token") + var stdout, stderr bytes.Buffer + code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + if gotAuth != "Bearer ping-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer ping-token") + } +} + +func TestCmdQueryForwardsBearerToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"columns":[],"rows":[]}`)) + })) + defer srv.Close() + + t.Setenv("SENTRYCTL_TOKEN", "query-token") + var stdout, stderr bytes.Buffer + code := cmdQuery([]string{"--api", srv.URL, "service=api"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + if gotAuth != "Bearer query-token" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer query-token") + } +} diff --git a/cli/cmd/sentryctl/cmd_alerts.go b/cli/cmd/sentryctl/cmd_alerts.go index c7600b5..7647caf 100644 --- a/cli/cmd/sentryctl/cmd_alerts.go +++ b/cli/cmd/sentryctl/cmd_alerts.go @@ -12,16 +12,17 @@ func cmdAlerts(args []string, stdout, stderr io.Writer) int { return 1 } alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv) + token := resolveToken(os.Getenv) switch args[0] { case "list": - return httpGetJSON(alertingURL, "/rules", stdout, stderr) + return httpGetJSON(alertingURL, "/rules", token, stdout, stderr) case "get": if len(rest) == 0 { fmt.Fprintln(stderr, "sentryctl alerts get: missing rule id") return 1 } - return httpGetJSON(alertingURL, "/rules/"+rest[0], stdout, stderr) + return httpGetJSON(alertingURL, "/rules/"+rest[0], token, stdout, stderr) case "apply": if len(rest) == 0 { fmt.Fprintln(stderr, "sentryctl alerts apply: missing file path") @@ -30,7 +31,7 @@ func cmdAlerts(args []string, stdout, stderr io.Writer) int { // POST /rules accepts the same shape it returns -- a rule // definition file (query, condition, interval, notification // target ID) applies directly with no reshaping. - return httpPostFileJSON(alertingURL, "/rules", rest[0], stdout, stderr) + return httpPostFileJSON(alertingURL, "/rules", token, rest[0], stdout, stderr) default: fmt.Fprintf(stderr, "sentryctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0]) return 1 diff --git a/cli/cmd/sentryctl/cmd_dashboards.go b/cli/cmd/sentryctl/cmd_dashboards.go index 5f2fa21..5ab205f 100644 --- a/cli/cmd/sentryctl/cmd_dashboards.go +++ b/cli/cmd/sentryctl/cmd_dashboards.go @@ -12,16 +12,17 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int { return 1 } apiURL, rest := extractAPIFlag(args[1:], os.Getenv) + token := resolveToken(os.Getenv) switch args[0] { case "list": - return httpGetJSON(apiURL, "/dashboards", stdout, stderr) + return httpGetJSON(apiURL, "/dashboards", token, stdout, stderr) case "get": if len(rest) == 0 { fmt.Fprintln(stderr, "sentryctl dashboards get: missing dashboard id") return 1 } - return httpGetJSON(apiURL, "/dashboards/"+rest[0], stdout, stderr) + return httpGetJSON(apiURL, "/dashboards/"+rest[0], token, stdout, stderr) case "apply": if len(rest) == 0 { fmt.Fprintln(stderr, "sentryctl dashboards apply: missing file path") @@ -30,7 +31,7 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int { // The import endpoint consumes exactly the shape GET // /dashboards/{id}/export produces and the web UI's Export JSON // button downloads -- one JSON contract, three call sites. - return httpPostFileJSON(apiURL, "/dashboards/import", rest[0], stdout, stderr) + return httpPostFileJSON(apiURL, "/dashboards/import", token, rest[0], stdout, stderr) default: fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply)\n", args[0]) return 1 diff --git a/cli/cmd/sentryctl/cmd_ping.go b/cli/cmd/sentryctl/cmd_ping.go index 2e2de49..78162d7 100644 --- a/cli/cmd/sentryctl/cmd_ping.go +++ b/cli/cmd/sentryctl/cmd_ping.go @@ -26,8 +26,15 @@ func parsePingArgs(args []string, env func(string) string) string { func cmdPing(args []string, stdout, stderr io.Writer) int { apiURL := parsePingArgs(args, os.Getenv) + req, err := http.NewRequest(http.MethodGet, apiURL+"/healthz", nil) + if err != nil { + fmt.Fprintf(stderr, "building request: %v\n", err) + return 1 + } + setAuth(req, resolveToken(os.Getenv)) + client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(apiURL + "/healthz") + resp, err := client.Do(req) if err != nil { fmt.Fprintf(stderr, "ping failed: %v\n", err) return 1 diff --git a/cli/cmd/sentryctl/cmd_query.go b/cli/cmd/sentryctl/cmd_query.go index 79beae4..823bf72 100644 --- a/cli/cmd/sentryctl/cmd_query.go +++ b/cli/cmd/sentryctl/cmd_query.go @@ -72,8 +72,16 @@ func cmdQuery(args []string, stdout, stderr io.Writer) int { return 1 } + req, err := http.NewRequest(http.MethodPost, qa.apiURL+"/query", bytes.NewReader(reqBody)) + if err != nil { + fmt.Fprintf(stderr, "building request: %v\n", err) + return 1 + } + req.Header.Set("Content-Type", "application/json") + setAuth(req, resolveToken(os.Getenv)) + client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Post(qa.apiURL+"/query", "application/json", bytes.NewReader(reqBody)) + resp, err := client.Do(req) if err != nil { fmt.Fprintf(stderr, "query failed: %v\n", err) return 1 diff --git a/cli/cmd/sentryctl/httpclient.go b/cli/cmd/sentryctl/httpclient.go index 863b80d..de9ef8b 100644 --- a/cli/cmd/sentryctl/httpclient.go +++ b/cli/cmd/sentryctl/httpclient.go @@ -12,11 +12,26 @@ import ( var httpClient = &http.Client{Timeout: 30 * time.Second} +// setAuth attaches SENTRYCTL_TOKEN (see resolveToken) as a Bearer +// credential, a no-op when token is empty -- matches every backend's +// nil-authorizer no-op default (see api/internal/authz.RequireRole*). +func setAuth(req *http.Request, token string) { + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + // httpGetJSON GETs path and prints the pretty-printed JSON response to // stdout, or the error body/status to stderr. Shared by dashboards/alerts // list and get, which otherwise differ only in path and resource name. -func httpGetJSON(baseURL, path string, stdout, stderr io.Writer) int { - resp, err := httpClient.Get(baseURL + path) +func httpGetJSON(baseURL, path, token string, stdout, stderr io.Writer) int { + req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) + if err != nil { + fmt.Fprintf(stderr, "building request: %v\n", err) + return 1 + } + setAuth(req, token) + resp, err := httpClient.Do(req) if err != nil { fmt.Fprintf(stderr, "request failed: %v\n", err) return 1 @@ -31,13 +46,20 @@ func httpGetJSON(baseURL, path string, stdout, stderr io.Writer) int { // expects (the same JSON the web UI's export button and POST /rules // produce/accept respectively). This is what makes "apply" the seed of a // future Terraform provider: one JSON contract, multiple callers. -func httpPostFileJSON(baseURL, path, file string, stdout, stderr io.Writer) int { +func httpPostFileJSON(baseURL, path, token, file string, stdout, stderr io.Writer) int { body, err := os.ReadFile(file) if err != nil { fmt.Fprintf(stderr, "reading %s: %v\n", file, err) return 1 } - resp, err := httpClient.Post(baseURL+path, "application/json", bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + fmt.Fprintf(stderr, "building request: %v\n", err) + return 1 + } + req.Header.Set("Content-Type", "application/json") + setAuth(req, token) + resp, err := httpClient.Do(req) if err != nil { fmt.Fprintf(stderr, "request failed: %v\n", err) return 1 diff --git a/cli/cmd/sentryctl/main.go b/cli/cmd/sentryctl/main.go index 9875e20..84c5ac9 100644 --- a/cli/cmd/sentryctl/main.go +++ b/cli/cmd/sentryctl/main.go @@ -73,7 +73,13 @@ Commands: --api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset. --alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset. ---language overrides auto-detection; omit it for the common case.`) +--language overrides auto-detection; omit it for the common case. + +$SENTRYCTL_TOKEN, if set, is sent as "Authorization: Bearer " on +every request -- required once a deployment configures enterprise-auth +(see /docs/phase-4-rbac-design.md). No flag equivalent, deliberately: +unlike --api, a credential shouldn't be typed where shell history or +`+"`ps`"+` output can capture it.`) } func resolveAPIURL(env func(string) string) string { @@ -90,6 +96,14 @@ func resolveAlertingURL(env func(string) string) string { return defaultAlertingURL } +// resolveToken reads the RoleService/human bearer credential sentryctl +// presents to api/alerting once enterprise-auth enforcement is turned +// on (api/internal/authz.RequireRole*) -- empty by default, matching +// every other Phase 0-3 client's nil-authorizer no-op behavior. +func resolveToken(env func(string) string) string { + return env("SENTRYCTL_TOKEN") +} + type errorResponseBody struct { Error string `json:"error"` } diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..efaffeb --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,78 @@ +# deploy + +Kubernetes deployment for Sentry, added in Phase 4 (`/deploy` was +deliberately stubbed through Phase 3 -- see `/CLAUDE.md`'s Phase 3 +non-goals). Two pieces: + +- `operator/` -- a small Go controller-runtime Operator managing one CRD + (`Tenant`). See `operator/README.md`. +- `helm/sentry/` -- a Helm chart covering every `docker-compose.yml` + service, plus the operator and `Tenant` CRs when + `enterprise.enabled=true`. See `helm/sentry/README.md`. + +## What "multi-tenant-aware" means here, precisely + +Per `/docs/phase-4-isolation-design.md`, tenant isolation itself lives at +the **application layer** inside `enterprise/` (one `api` process holds a +map of per-tenant ClickHouse connection pools; one `search` process holds +a map of per-tenant Tantivy indices) -- not at the Kubernetes layer. This +directory is **not** "one Deployment per tenant" or a general +multi-cluster system; that's an explicit Phase 4 non-goal (see +`/CLAUDE.md`). What it *does* add, matching that same document's exit +criteria ("real per-tenant secret management, replacing today's single +shared `CLICKHOUSE_PASSWORD`"): + +- A `Tenant` CRD + controller that generates and manages one dedicated + ClickHouse credential Secret per tenant (`operator/internal/controller`). +- A Helm chart that can install zero-or-more `Tenant` CRs + (`values.tenants`) alongside the rest of the stack. + +The Operator does **not** call ClickHouse (no `CREATE DATABASE`/`CREATE +USER`/`GRANT`) and does not touch the Tantivy filesystem or +`enterprise/internal/rbacstore` -- that's `enterprise/internal/ +tenantprovision`, still unbuilt (see the Phase 4 task 5 summary). A +`Tenant` reaching `status.phase: Active` here means "this tenant has a +K8s Secret," not "this tenant's ClickHouse database/grants exist" -- +those are two different systems' state machines that aren't reconciled +together yet, named explicitly rather than implied. + +## Verification status -- read before trusting this against a real cluster + +**Not verified against a live Kubernetes cluster.** This environment has +no `kubectl`/`kind`/`minikube`/`kubebuilder`/cluster reachable, so +nothing here has been `kubectl apply`'d or `helm install`'d for real. +Same disclosed-limitation shape as `/agent/README.md`'s "Windows-specific +agent code remains unverified on real Windows" from Phase 1 -- a real gap +to close before shipping, not swept under the rug. + +What **was** actually verified, offline, in this environment (network +access was available to fetch these tools, but no cluster): + +- `deploy/operator`: `go build`/`go vet`/`go test ./...` all pass, + including reconciler tests against controller-runtime's fake client + (`internal/controller/tenant_controller_test.go`) -- real reconcile + logic exercised, but not against a real apiserver (no `envtest` + binaries available; see that test file's doc comment). +- `deploy/operator/config/crd/sentry.io_tenants.yaml`: parsed with + `sigs.k8s.io/yaml` + strict-unmarshaled into the real + `k8s.io/apiextensions-apiserver` `CustomResourceDefinition` Go type -- + catches YAML syntax errors and structural mistakes, not a live-cluster + admission check. +- `deploy/helm/sentry`: `helm lint` passes; `helm template` renders + cleanly under both default values and a `enterprise.enabled: true` + + two-tenant override; the rendered output was checked with `kubeconform + -strict` against the real Kubernetes 1.31 OpenAPI schema for every + built-in resource kind (22-29 resources depending on values, 0 + invalid) -- this catches schema mistakes (wrong field names, wrong + types) but not whether the resources actually reconcile correctly + together on a live cluster (Job/StatefulSet startup ordering, PVC + provisioning, actual pod scheduling). +- Docker image builds (`operator/Dockerfile` and every other + `Dockerfile` this chart references) were **not** verified in this + session -- Docker's daemon wasn't reachable here either (see the + Phase 4 task 5 conversation for why). Build and push every image this + chart's `values.yaml` references before installing it. + +Before relying on this in production: `kind create cluster`, `helm +install` with `--include-crds`, and walk through +`helm/sentry/README.md`'s two-tenant example end to end. diff --git a/deploy/helm/sentry/Chart.yaml b/deploy/helm/sentry/Chart.yaml new file mode 100644 index 0000000..091412e --- /dev/null +++ b/deploy/helm/sentry/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: sentry +description: >- + Sentry: Kubernetes-native distributed log aggregation. Phase 4 adds + multi-tenant-aware deployment (per-tenant ClickHouse credential Secrets + via the tenant-operator, optional enterprise-auth) on top of the same + single-cluster topology Phases 0-3 ran under docker-compose -- see + deploy/README.md for what "multi-tenant-aware" does and doesn't mean + here. +type: application +version: 0.4.0 +appVersion: "phase-4" diff --git a/deploy/helm/sentry/README.md b/deploy/helm/sentry/README.md new file mode 100644 index 0000000..f4bc473 --- /dev/null +++ b/deploy/helm/sentry/README.md @@ -0,0 +1,85 @@ +# deploy/helm/sentry + +A Helm chart covering every `docker-compose.yml` service (Redpanda, +ClickHouse, Postgres, ingest, search, api, alerting, web) plus, when +`enterprise.enabled: true`: enterprise-auth, the `deploy/operator` +tenant-operator, and `Tenant` CRs from `values.tenants`. See +`/deploy/README.md` for what "multi-tenant-aware" does and doesn't mean +at this layer, and its verification-status section before trusting this +against a real cluster. + +This chart never builds images -- push every image its `values.yaml` +references to a registry the cluster can pull from first, same division +of labor as `docker compose build` vs. `docker compose up`. + +## Startup ordering + +`docker-compose.yml` uses `depends_on: condition: service_healthy` / +`service_completed_successfully` to sequence startup (e.g. `api` waits +for `clickhouse-migrate` to actually finish, not just for `clickhouse` to +be reachable). This chart approximates that more loosely: + +- Migration Jobs (`clickhouse-migrate`, `metadata-migrate`, + `redpanda-provision`) are plain `Job` resources (not Helm hooks -- + making the StatefulSets they depend on into hooks too, to get + ordering, would break `helm upgrade`/`helm uninstall`'s normal + ownership tracking of stateful resources, a worse tradeoff), with + `backoffLimit: 6` so they retry a few times if their dependency isn't + up yet. +- App Deployments get an `initContainer` that busy-waits for their + dependency's **TCP port**, not for a specific Job's completion (see + `templates/_helpers.tpl`'s `sentry.waitForTCP`) -- this covers "is + ClickHouse/Postgres/Redpanda up" but not "has the migration Job + actually finished." +- The gap that leaves (a pod starts before its migration has completed) + is covered by every Go service here already calling `os.Exit(1)` on a + failed startup DB ping (see e.g. `api/cmd/api/main.go`) -- + Kubernetes' pod restart policy retries with backoff until the schema + is ready. This is a real, working, but *looser* guarantee than + docker-compose's explicit ordering -- documented here rather than + implied to be equivalent. + +## Trying the two-tenant example + +```sh +# Quote each --set value -- zsh globs an unquoted tenants[0] as a +# pattern and fails with "no matches found." +helm install sentry . --include-crds \ + --set enterprise.enabled=true \ + --set tenantOperator.enabled=true \ + --set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' \ + --set 'tenants[1].name=globex' --set 'tenants[1].displayName=Globex Corporation' + +kubectl get tenants +kubectl get secret sentry-tenant-acme-clickhouse sentry-tenant-globex-clickhouse +``` + +This proves the K8s-side half of Phase 4's "two tenants... with their +own users, roles, dashboards" exit criteria (`/CLAUDE.md`) -- a real +per-tenant credential Secret exists for each. It does **not** by itself +give either tenant a working login, dashboard, or ClickHouse database: +those need the OIDC/SAML login handlers, `internal/tenantprovision`, and +`internal/rbacstore` wiring the Phase 4 task 5 summary names as deferred. + +## `web`'s image needs rebuilding per environment + +`web` is a static SvelteKit build (`adapter-static`) -- its three API +base URLs (`VITE_API_BASE_URL`/`VITE_ALERTING_API_BASE_URL`/ +`VITE_ENTERPRISE_AUTH_BASE_URL`) are baked in at **image build time** +(`web/Dockerfile`'s build args), not read from the container's +environment at runtime. `values.yaml`'s `web.builtWithApiBaseURL` etc. +document what the image you point `web.image` at needs to have been +built with (an Ingress hostname, a LoadBalancer IP, etc.) -- this chart +has no Ingress resources and can't itself act on those values; rebuild +`web`'s image with the right build args for wherever this release is +actually reachable from a browser before pointing real users at it. + +## Validating without a cluster + +```sh +helm lint . +helm template sentry . --include-crds > /tmp/rendered.yaml +``` + +See `/deploy/README.md`'s verification section for what was actually +checked this way (and what wasn't -- no live cluster was available). diff --git a/deploy/helm/sentry/crds/sentry.io_tenants.yaml b/deploy/helm/sentry/crds/sentry.io_tenants.yaml new file mode 100644 index 0000000..5120cf0 --- /dev/null +++ b/deploy/helm/sentry/crds/sentry.io_tenants.yaml @@ -0,0 +1,93 @@ +# Hand-written, not `controller-gen crd` output -- see +# api/v1alpha1/groupversion_info.go's doc comment. Kept in sync with +# api/v1alpha1/tenant_types.go by hand; api/v1alpha1/api_test.go's +# round-trip tests catch a Go/YAML drift in the *shape* of the types, +# but not a drift in this file's field descriptions/validation rules -- +# review both together when either changes. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: tenants.sentry.io +spec: + group: sentry.io + names: + kind: Tenant + listKind: TenantList + plural: tenants + singular: tenant + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Phase + type: string + jsonPath: .status.phase + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + description: >- + Tenant is the K8s-native representation of one Sentry tenant's + deployment-topology state -- see + deploy/operator/internal/controller/tenant_controller.go's doc + comment for what the controller does and does not manage. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: [displayName] + properties: + displayName: + type: string + description: Human-readable only -- the object's own metadata.name is the stable identifier. + suspended: + type: boolean + description: Admin-facing lever for the Suspended phase. + default: false + status: + type: object + properties: + phase: + type: string + enum: [Provisioning, Active, Suspended, Deprovisioning] + clickHouseDatabaseName: + type: string + clickHouseSecretRef: + type: string + tantivyIndexPath: + type: string + observedGeneration: + type: integer + format: int64 + conditions: + type: array + items: + type: object + required: [type, status] + properties: + type: + type: string + status: + type: string + enum: ["True", "False", "Unknown"] + reason: + type: string + message: + type: string + observedGeneration: + type: integer + format: int64 + lastTransitionTime: + type: string + format: date-time diff --git a/deploy/helm/sentry/templates/_helpers.tpl b/deploy/helm/sentry/templates/_helpers.tpl new file mode 100644 index 0000000..c54fc4a --- /dev/null +++ b/deploy/helm/sentry/templates/_helpers.tpl @@ -0,0 +1,55 @@ +{{/* +Standard labels applied to every resource this chart renders. +*/}} +{{- define "sentry.labels" -}} +app.kubernetes.io/part-of: sentry +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end -}} + +{{/* +Per-component selector labels -- usage: +{{ include "sentry.selectorLabels" (list $ "api") }} +A plain string arg (the old shape this started with) can't reach +$.Release from inside the defined template -- `include`'s argument +becomes the template's entire root context, so a bare "api" string +leaves no way to get back to the chart root. A two-element list carries +both. +*/}} +{{- define "sentry.selectorLabels" -}} +{{- $root := index . 0 -}} +{{- $name := index . 1 -}} +app.kubernetes.io/name: sentry-{{ $name }} +app.kubernetes.io/instance: {{ $root.Release.Name }} +{{- end -}} + +{{/* +An initContainer that busy-waits for a TCP host:port to accept +connections -- usage: {{ include "sentry.waitForTCP" (list "name-suffix" "host" "port") }} + +This approximates docker-compose.yml's `depends_on: condition: +service_healthy` (waits for the dependency's process to be reachable), +but NOT `condition: service_completed_successfully` (waits for a +one-shot Job, like clickhouse-migrate, to have actually finished). That +second guarantee doesn't have a lightweight equivalent here without +giving every app pod's ServiceAccount RBAC to read Job status, which is +a lot of privilege for a startup-ordering nicety -- see +deploy/helm/sentry/README.md's "Startup ordering" section. The gap it +leaves (a pod starts before its migration Job has finished) is covered +by the app's own crash-and-restart-on-connect/schema failure: every Go +service here already os.Exit(1)s on a failed Postgres/ClickHouse ping at +startup (see e.g. api/cmd/api/main.go), so Kubernetes' restart policy +naturally retries until the schema is ready. Documented as a real, +accepted tradeoff, not implied to be a hard ordering guarantee. +*/}} +{{- define "sentry.waitForTCP" -}} +{{- $name := index . 0 -}} +{{- $host := index . 1 -}} +{{- $port := index . 2 -}} +- name: wait-for-{{ $name }} + image: busybox:1.36 + command: + - sh + - -c + - until nc -z -w2 {{ $host }} {{ $port }}; do echo "waiting for {{ $host }}:{{ $port }}"; sleep 2; done +{{- end -}} diff --git a/deploy/helm/sentry/templates/alerting.yaml b/deploy/helm/sentry/templates/alerting.yaml new file mode 100644 index 0000000..3448ec8 --- /dev/null +++ b/deploy/helm/sentry/templates/alerting.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-alerting + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "alerting") | nindent 4 }} +spec: + # See values.yaml's comment: replicas is not a real knob here yet. + replicas: {{ .Values.alerting.replicas }} + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "alerting") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "alerting") | nindent 8 }} + spec: + initContainers: + {{- include "sentry.waitForTCP" (list "postgres" (printf "%s-postgres" .Release.Name) "5432") | nindent 8 }} + {{- include "sentry.waitForTCP" (list "api" (printf "%s-api" .Release.Name) "8080") | nindent 8 }} + containers: + - name: alerting + image: "{{ .Values.alerting.image.repository }}:{{ .Values.alerting.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: POSTGRES_ADDR + value: "{{ .Release.Name }}-postgres:5432" + - name: POSTGRES_DATABASE + value: sentry_metadata + - name: POSTGRES_USERNAME + value: sentry + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgres + key: password + - name: API_QUERY_URL + value: "http://{{ .Release.Name }}-api:8080" + {{- if .Values.enterprise.enabled }} + # RoleService credential for POST /query, once api's + # ENTERPRISE_AUTH_URL enforcement is on -- see + # /docs/phase-4-isolation-design.md's alerting<->api gap and + # alerting/internal/queryclient's doc comment. NOT generated + # by this chart: mint one with + # `enterprise-auth -mint-service-token=alerting` (see + # enterprise/README.md) and supply it via + # --set-string alerting.apiServiceToken=... or a values + # override backed by a Secret you manage -- a chart + # generating its own long-lived service credential and + # storing it in the same release's values would defeat the + # point of it being a distinct, revocable credential. + {{- if .Values.alerting.apiServiceToken }} + - name: API_SERVICE_TOKEN + value: {{ .Values.alerting.apiServiceToken | quote }} + {{- end }} + {{- end }} + ports: + - name: http + containerPort: 8081 + readinessProbe: + exec: + command: ["/alerting", "-healthcheck"] + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.alerting.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-alerting + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "alerting") | nindent 4 }} +spec: + selector: + {{- include "sentry.selectorLabels" (list $ "alerting") | nindent 4 }} + ports: + - name: http + port: 8081 diff --git a/deploy/helm/sentry/templates/api.yaml b/deploy/helm/sentry/templates/api.yaml new file mode 100644 index 0000000..f3262d5 --- /dev/null +++ b/deploy/helm/sentry/templates/api.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-api + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "api") | nindent 4 }} +spec: + replicas: {{ .Values.api.replicas }} + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "api") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "api") | nindent 8 }} + spec: + initContainers: + {{- include "sentry.waitForTCP" (list "clickhouse" (printf "%s-clickhouse" .Release.Name) "9000") | nindent 8 }} + {{- include "sentry.waitForTCP" (list "postgres" (printf "%s-postgres" .Release.Name) "5432") | nindent 8 }} + {{- include "sentry.waitForTCP" (list "search" (printf "%s-search" .Release.Name) "50052") | nindent 8 }} + containers: + - name: api + image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: CLICKHOUSE_ADDR + value: "{{ .Release.Name }}-clickhouse:9000" + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-clickhouse + key: password + - name: SEARCH_GRPC_ADDR + value: "{{ .Release.Name }}-search:50052" + - name: POSTGRES_ADDR + value: "{{ .Release.Name }}-postgres:5432" + - name: POSTGRES_DATABASE + value: sentry_metadata + - name: POSTGRES_USERNAME + value: sentry + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgres + key: password + {{- if .Values.enterprise.enabled }} + # Turns on authz.RequireRole*/RequireRoleOrService enforcement + # on /query and /dashboards -- see api/internal/authz and + # /docs/phase-4-rbac-design.md. Off (unset) when + # enterprise.enabled is false, matching every nil-authorizer + # no-op default in this codebase. + - name: ENTERPRISE_AUTH_URL + value: "http://{{ .Release.Name }}-enterprise-auth:8082" + {{- end }} + ports: + - name: http + containerPort: 8080 + readinessProbe: + exec: + command: ["/api", "-healthcheck"] + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.api.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-api + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "api") | nindent 4 }} +spec: + selector: + {{- include "sentry.selectorLabels" (list $ "api") | nindent 4 }} + ports: + - name: http + port: 8080 diff --git a/deploy/helm/sentry/templates/clickhouse.yaml b/deploy/helm/sentry/templates/clickhouse.yaml new file mode 100644 index 0000000..88c59ec --- /dev/null +++ b/deploy/helm/sentry/templates/clickhouse.yaml @@ -0,0 +1,103 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .Release.Name }}-clickhouse + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 4 }} +spec: + serviceName: {{ .Release.Name }}-clickhouse + replicas: 1 + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 8 }} + spec: + containers: + - name: clickhouse + image: "{{ .Values.clickhouse.image.repository }}:{{ .Values.clickhouse.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + # Required to avoid the official image's network lockdown of + # the implicit `default` user -- see values.yaml's comment on + # this password and docker-compose.yml's original. + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-clickhouse + key: password + ports: + - name: http + containerPort: 8123 + - name: native + containerPort: 9000 + volumeMounts: + - name: data + mountPath: /var/lib/clickhouse + readinessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.clickhouse.resources | nindent 12 }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.clickhouse.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-clickhouse + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 4 }} +spec: + clusterIP: None + selector: + {{- include "sentry.selectorLabels" (list $ "clickhouse") | nindent 4 }} + ports: + - name: http + port: 8123 + - name: native + port: 9000 +--- +# One-shot: applies /storage/migrations/*.sql -- same image +# storage/Dockerfile builds for docker-compose.yml's clickhouse-migrate +# service. Plain Job, not a Helm hook -- see redpanda.yaml's comment and +# deploy/helm/sentry/README.md's "Startup ordering" section. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-clickhouse-migrate + labels: + {{- include "sentry.labels" . | nindent 4 }} +spec: + backoffLimit: 6 + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "clickhouse-migrate") | nindent 8 }} + spec: + restartPolicy: OnFailure + containers: + - name: clickhouse-migrate + image: "{{ .Values.clickhouse.migrateImage.repository }}:{{ .Values.clickhouse.migrateImage.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: CLICKHOUSE_HTTP + value: "http://{{ .Release.Name }}-clickhouse:8123" + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-clickhouse + key: password diff --git a/deploy/helm/sentry/templates/enterprise-auth.yaml b/deploy/helm/sentry/templates/enterprise-auth.yaml new file mode 100644 index 0000000..ba6f4d0 --- /dev/null +++ b/deploy/helm/sentry/templates/enterprise-auth.yaml @@ -0,0 +1,74 @@ +{{- if .Values.enterprise.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-enterprise-auth + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 4 }} +spec: + replicas: {{ .Values.enterprise.replicas }} + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 8 }} + spec: + containers: + - name: enterprise-auth + image: "{{ .Values.enterprise.image.repository }}:{{ .Values.enterprise.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: ENTERPRISE_SESSION_SIGNING_KEY + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-enterprise-auth + key: sessionSigningKey + {{- if .Values.enterprise.oidc.issuerURL }} + - name: OIDC_ISSUER_URL + value: {{ .Values.enterprise.oidc.issuerURL | quote }} + - name: OIDC_CLIENT_ID + value: {{ .Values.enterprise.oidc.clientID | quote }} + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-enterprise-auth + key: oidcClientSecret + - name: OIDC_REDIRECT_URL + value: {{ .Values.enterprise.oidc.redirectURL | quote }} + {{- end }} + {{- if .Values.enterprise.saml.idpMetadataURL }} + - name: SAML_ENTITY_ID + value: {{ .Values.enterprise.saml.entityID | quote }} + - name: SAML_ACS_URL + value: {{ .Values.enterprise.saml.acsURL | quote }} + - name: SAML_IDP_METADATA_URL + value: {{ .Values.enterprise.saml.idpMetadataURL | quote }} + {{- end }} + ports: + - name: http + containerPort: 8082 + readinessProbe: + exec: + command: ["/enterprise-auth", "-healthcheck"] + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.enterprise.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-enterprise-auth + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 4 }} +spec: + selector: + {{- include "sentry.selectorLabels" (list $ "enterprise-auth") | nindent 4 }} + ports: + - name: http + port: 8082 +{{- end }} diff --git a/deploy/helm/sentry/templates/ingest.yaml b/deploy/helm/sentry/templates/ingest.yaml new file mode 100644 index 0000000..f0b8cf3 --- /dev/null +++ b/deploy/helm/sentry/templates/ingest.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-ingest + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "ingest") | nindent 4 }} +spec: + replicas: {{ .Values.ingest.replicas }} + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "ingest") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "ingest") | nindent 8 }} + spec: + initContainers: + {{- include "sentry.waitForTCP" (list "redpanda" (printf "%s-redpanda" .Release.Name) "9092") | nindent 8 }} + {{- include "sentry.waitForTCP" (list "clickhouse" (printf "%s-clickhouse" .Release.Name) "9000") | nindent 8 }} + containers: + - name: ingest + image: "{{ .Values.ingest.image.repository }}:{{ .Values.ingest.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: REDPANDA_BROKERS + value: "{{ .Release.Name }}-redpanda:9092" + - name: CLICKHOUSE_ADDR + value: "{{ .Release.Name }}-clickhouse:9000" + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-clickhouse + key: password + ports: + - name: grpc + containerPort: 4317 + {{- if .Values.ingest.tlsSecretName }} + volumeMounts: + - name: tls + mountPath: /etc/sentry-ingest + readOnly: true + {{- end }} + resources: + {{- toYaml .Values.ingest.resources | nindent 12 }} + {{- if .Values.ingest.tlsSecretName }} + volumes: + - name: tls + secret: + secretName: {{ .Values.ingest.tlsSecretName }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-ingest + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "ingest") | nindent 4 }} +spec: + selector: + {{- include "sentry.selectorLabels" (list $ "ingest") | nindent 4 }} + ports: + - name: grpc + port: 4317 diff --git a/deploy/helm/sentry/templates/postgres.yaml b/deploy/helm/sentry/templates/postgres.yaml new file mode 100644 index 0000000..cfaf1a1 --- /dev/null +++ b/deploy/helm/sentry/templates/postgres.yaml @@ -0,0 +1,110 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .Release.Name }}-postgres + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "postgres") | nindent 4 }} +spec: + serviceName: {{ .Release.Name }}-postgres + replicas: 1 + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "postgres") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "postgres") | nindent 8 }} + spec: + containers: + - name: postgres + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: POSTGRES_DB + value: sentry_metadata + - name: POSTGRES_USER + value: sentry + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgres + key: password + ports: + - name: postgres + containerPort: 5432 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "sentry", "-d", "sentry_metadata"] + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.postgres.resources | nindent 12 }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.postgres.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-postgres + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "postgres") | nindent 4 }} +spec: + clusterIP: None + selector: + {{- include "sentry.selectorLabels" (list $ "postgres") | nindent 4 }} + ports: + - name: postgres + port: 5432 +--- +# One-shot: applies /metadata/migrations/*.sql (including Phase 4's +# tenants/users/tenant_memberships/audit_log schema) -- same image +# metadata/Dockerfile builds for docker-compose.yml's metadata-migrate +# service. Plain Job, not a Helm hook -- see redpanda.yaml's comment. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-metadata-migrate + labels: + {{- include "sentry.labels" . | nindent 4 }} +spec: + backoffLimit: 6 + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "metadata-migrate") | nindent 8 }} + spec: + restartPolicy: OnFailure + containers: + - name: metadata-migrate + image: "{{ .Values.postgres.migrateImage.repository }}:{{ .Values.postgres.migrateImage.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: POSTGRES_HOST + value: "{{ .Release.Name }}-postgres" + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_USER + value: sentry + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgres + key: password + - name: POSTGRES_DATABASE + value: sentry_metadata + - name: AUDIT_WRITER_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-postgres + key: auditWriterPassword diff --git a/deploy/helm/sentry/templates/redpanda.yaml b/deploy/helm/sentry/templates/redpanda.yaml new file mode 100644 index 0000000..c656b29 --- /dev/null +++ b/deploy/helm/sentry/templates/redpanda.yaml @@ -0,0 +1,108 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .Release.Name }}-redpanda + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 4 }} +spec: + serviceName: {{ .Release.Name }}-redpanda + replicas: 1 + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 8 }} + spec: + containers: + - name: redpanda + image: "{{ .Values.redpanda.image.repository }}:{{ .Values.redpanda.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + args: + - redpanda + - start + - --smp=1 + - --memory=1G + - --reserve-memory=0M + - --overprovisioned + - --node-id=0 + - --check=false + - --kafka-addr=PLAINTEXT://0.0.0.0:9092 + - --advertise-kafka-addr=PLAINTEXT://{{ .Release.Name }}-redpanda:9092 + ports: + - name: kafka + containerPort: 9092 + - name: admin + containerPort: 9644 + volumeMounts: + - name: data + mountPath: /var/lib/redpanda/data + readinessProbe: + exec: + command: ["rpk", "cluster", "health", "--exit-when-healthy"] + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + {{- toYaml .Values.redpanda.resources | nindent 12 }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.redpanda.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-redpanda + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 4 }} +spec: + clusterIP: None + selector: + {{- include "sentry.selectorLabels" (list $ "redpanda") | nindent 4 }} + ports: + - name: kafka + port: 9092 + - name: admin + port: 9644 +--- +# One-shot: creates the sentry.logs.raw topic. Same image +# transport/Dockerfile builds for docker-compose.yml's redpanda-provision +# service. Deliberately a plain Job, not a Helm hook -- see +# deploy/helm/sentry/README.md's "Startup ordering" section for why +# (StatefulSets-as-hooks breaks helm upgrade/uninstall's ownership +# tracking of stateful resources). backoffLimit gives it room to retry +# until redpanda's StatefulSet is actually ready; ingest/search's own +# crash-and-restart-on-connect-failure covers the rest of the ordering, +# same as every dependency in this chart. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-redpanda-provision + labels: + {{- include "sentry.labels" . | nindent 4 }} +spec: + backoffLimit: 6 + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "redpanda-provision") | nindent 8 }} + spec: + restartPolicy: OnFailure + containers: + - name: redpanda-provision + image: "{{ .Values.redpanda.provisionImage.repository }}:{{ .Values.redpanda.provisionImage.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: REDPANDA_BROKERS + value: "{{ .Release.Name }}-redpanda:9092" + - name: REDPANDA_ADMIN_HOSTS + value: "{{ .Release.Name }}-redpanda:9644" + - name: REDPANDA_TOPIC_PARTITIONS + value: "6" diff --git a/deploy/helm/sentry/templates/search.yaml b/deploy/helm/sentry/templates/search.yaml new file mode 100644 index 0000000..369d35b --- /dev/null +++ b/deploy/helm/sentry/templates/search.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-search + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "search") | nindent 4 }} +spec: + # See values.yaml's comment: replicas is not a real knob here yet. + replicas: {{ .Values.search.replicas }} + strategy: + type: Recreate # single PVC below (ReadWriteOnce) -- avoid two pods racing to mount it during a rollout + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "search") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "search") | nindent 8 }} + spec: + initContainers: + {{- include "sentry.waitForTCP" (list "redpanda" (printf "%s-redpanda" .Release.Name) "9092") | nindent 8 }} + containers: + - name: search + image: "{{ .Values.search.image.repository }}:{{ .Values.search.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: REDPANDA_BROKERS + value: "{{ .Release.Name }}-redpanda:9092" + - name: REDPANDA_TOPIC_PARTITIONS + value: "6" + - name: RUST_LOG + value: "info" + ports: + - name: grpc + containerPort: 50052 + volumeMounts: + - name: index-data + mountPath: /var/lib/sentry-search + resources: + {{- toYaml .Values.search.resources | nindent 12 }} + volumes: + - name: index-data + persistentVolumeClaim: + claimName: {{ .Release.Name }}-search-index +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Release.Name }}-search-index + labels: + {{- include "sentry.labels" . | nindent 4 }} +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: {{ .Values.search.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-search + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "search") | nindent 4 }} +spec: + selector: + {{- include "sentry.selectorLabels" (list $ "search") | nindent 4 }} + ports: + - name: grpc + port: 50052 diff --git a/deploy/helm/sentry/templates/secrets.yaml b/deploy/helm/sentry/templates/secrets.yaml new file mode 100644 index 0000000..0bf2d22 --- /dev/null +++ b/deploy/helm/sentry/templates/secrets.yaml @@ -0,0 +1,81 @@ +{{/* +Shared control-plane secrets -- the cluster-wide passwords +docker-compose.yml hardcodes as "sentry-dev-only"/etc (see its +clickhouse/metadata-postgres/metadata-migrate comments) become real +generated-or-supplied Secrets here. Each follows the same pattern: a +values override wins if set, otherwise a value is generated once and +kept stable across `helm upgrade` via `lookup` (so upgrades don't +silently rotate a live credential out from under a running Deployment -- +same "never rotate a live credential without coordinating the +consumer-side change" reasoning as +deploy/operator/internal/controller/tenant_controller.go's +reconcileSecret). `lookup` returns nothing under `helm template` +(no live cluster) -- expected; see deploy/README.md's verification +section for what that means for this file specifically. +*/}} +{{- define "sentry.stableSecretValue" -}} +{{- $ns := index . 0 -}} +{{- $name := index . 1 -}} +{{- $key := index . 2 -}} +{{- $override := index . 3 -}} +{{- $existing := lookup "v1" "Secret" $ns $name -}} +{{- if $override -}} +{{ $override }} +{{- else if $existing -}} +{{ index $existing.data $key | b64dec }} +{{- else -}} +{{ randAlphaNum 40 }} +{{- end -}} +{{- end -}} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-clickhouse + labels: + {{- include "sentry.labels" . | nindent 4 }} +type: Opaque +stringData: + # The official clickhouse-server image locks down *network* access + # entirely for the implicit `default` user unless this is genuinely + # non-empty -- see docker-compose.yml's clickhouse service comment. + # Not a substitute for task 2's per-tenant credentials (still unbuilt + # -- see deploy/operator's Tenant controller); this is the shared + # admin/migration credential only. + password: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-clickhouse" .Release.Name) "password" .Values.clickhouse.password) }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-postgres + labels: + {{- include "sentry.labels" . | nindent 4 }} +type: Opaque +stringData: + password: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-postgres" .Release.Name) "password" .Values.postgres.password) }} + # Restricted audit_writer Postgres role (Phase 4 task 4) -- INSERT+SELECT + # only, via its own pool, never the shared role above. See + # /docs/phase-4-isolation-design.md's audit-logging section and + # metadata/README.md. + auditWriterPassword: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-postgres" .Release.Name) "auditWriterPassword" .Values.postgres.auditWriterPassword) }} +{{- if .Values.enterprise.enabled }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-enterprise-auth + labels: + {{- include "sentry.labels" . | nindent 4 }} +type: Opaque +stringData: + # Must be >= 32 bytes -- see enterprise/internal/config.Load and + # enterprise/internal/session.MinSigningKeyBytes. Rotating this + # invalidates every outstanding session/service token -- same + # "don't rotate a live credential silently" reasoning as above, + # which is why it's kept stable via the lookup above rather than + # regenerated on every `helm upgrade`. + sessionSigningKey: {{ include "sentry.stableSecretValue" (list .Release.Namespace (printf "%s-enterprise-auth" .Release.Name) "sessionSigningKey" .Values.enterprise.sessionSigningKey) }} + {{- if .Values.enterprise.oidc.clientSecret }} + oidcClientSecret: {{ .Values.enterprise.oidc.clientSecret | quote }} + {{- end }} +{{- end }} diff --git a/deploy/helm/sentry/templates/tenant-operator.yaml b/deploy/helm/sentry/templates/tenant-operator.yaml new file mode 100644 index 0000000..eeb895b --- /dev/null +++ b/deploy/helm/sentry/templates/tenant-operator.yaml @@ -0,0 +1,92 @@ +{{- if and .Values.enterprise.enabled .Values.tenantOperator.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }}-tenant-operator + labels: + {{- include "sentry.labels" . | nindent 4 }} +--- +# ClusterRole, not Role: Tenant is cluster-scoped-CRD-but-namespaced-object +# (see crds/sentry.io_tenants.yaml's scope: Namespaced), and this chart +# doesn't assume it's the only namespace the operator might one day watch +# -- narrowed to exactly the two resource types +# deploy/operator/internal/controller/tenant_controller.go's +# +kubebuilder:rbac markers name (tenants, tenants/status, secrets), not +# a wildcard grant. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-tenant-operator + labels: + {{- include "sentry.labels" . | nindent 4 }} +rules: + - apiGroups: ["sentry.io"] + resources: ["tenants"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["sentry.io"] + resources: ["tenants/status"] + verbs: ["get", "update", "patch"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-tenant-operator + labels: + {{- include "sentry.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-tenant-operator +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }}-tenant-operator + namespace: {{ .Release.Namespace }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-tenant-operator + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "tenant-operator") | nindent 4 }} +spec: + # One replica -- see deploy/operator/cmd/tenant-operator/main.go's + # comment: no leader election yet, a second replica could + # double-generate a Secret. + replicas: 1 + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "tenant-operator") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "tenant-operator") | nindent 8 }} + spec: + serviceAccountName: {{ .Release.Name }}-tenant-operator + containers: + - name: tenant-operator + image: "{{ .Values.tenantOperator.image.repository }}:{{ .Values.tenantOperator.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - name: metrics + containerPort: 8080 + - name: probes + containerPort: 8081 + readinessProbe: + httpGet: + path: /readyz + port: probes + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: probes + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + {{- toYaml .Values.tenantOperator.resources | nindent 12 }} +{{- end }} diff --git a/deploy/helm/sentry/templates/tenants.yaml b/deploy/helm/sentry/templates/tenants.yaml new file mode 100644 index 0000000..9e782a4 --- /dev/null +++ b/deploy/helm/sentry/templates/tenants.yaml @@ -0,0 +1,14 @@ +{{- if .Values.enterprise.enabled }} +{{- range .Values.tenants }} +--- +apiVersion: sentry.io/v1alpha1 +kind: Tenant +metadata: + name: {{ .name }} + labels: + {{- include "sentry.labels" $ | nindent 4 }} +spec: + displayName: {{ .displayName | default .name | quote }} + suspended: {{ .suspended | default false }} +{{- end }} +{{- end }} diff --git a/deploy/helm/sentry/templates/web.yaml b/deploy/helm/sentry/templates/web.yaml new file mode 100644 index 0000000..397764f --- /dev/null +++ b/deploy/helm/sentry/templates/web.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-web + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "web") | nindent 4 }} +spec: + replicas: {{ .Values.web.replicas }} + selector: + matchLabels: + {{- include "sentry.selectorLabels" (list $ "web") | nindent 6 }} + template: + metadata: + labels: + {{- include "sentry.selectorLabels" (list $ "web") | nindent 8 }} + spec: + containers: + - name: web + # No env vars here -- see values.yaml's web.builtWith* comment: + # this is a static build, its API base URLs are baked into the + # image, not configurable at the Deployment level. + image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - name: http + containerPort: 3000 + resources: + {{- toYaml .Values.web.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-web + labels: + {{- include "sentry.labels" . | nindent 4 }} + {{- include "sentry.selectorLabels" (list $ "web") | nindent 4 }} +spec: + selector: + {{- include "sentry.selectorLabels" (list $ "web") | nindent 4 }} + ports: + - name: http + port: 3000 diff --git a/deploy/helm/sentry/values.yaml b/deploy/helm/sentry/values.yaml new file mode 100644 index 0000000..617f29f --- /dev/null +++ b/deploy/helm/sentry/values.yaml @@ -0,0 +1,180 @@ +# Default values for the sentry chart. See deploy/helm/sentry/README.md +# for the multi-tenant-specific values (enterprise.*, tenants) and what +# "multi-tenant-aware" does and doesn't mean at this layer. +# +# Image repositories default to locally-built tags matching each +# service's docker-compose.yml container_name, minus the "sentry-" +# container_name prefix duplication -- push these to a registry this +# cluster can actually pull from before installing; this chart never +# builds images itself (same division of labor as docker-compose.yml: +# `docker compose build` vs. `docker compose up`). + +global: + imagePullPolicy: IfNotPresent + +redpanda: + image: + repository: docker.redpanda.com/redpandadata/redpanda + tag: v24.2.7 + persistence: + size: 10Gi + resources: {} + # Built from ./transport (docker-compose.yml's redpanda-provision + # service) -- the one-shot topic-creation Job below. + provisionImage: + repository: sentry-redpanda-provision + tag: latest + +clickhouse: + image: + repository: clickhouse/clickhouse-server + tag: "24.8" + persistence: + size: 20Gi + resources: {} + # Leave empty to auto-generate and persist across upgrades -- see + # templates/secrets.yaml's stableSecretValue helper. + password: "" + # Built from ./storage (docker-compose.yml's clickhouse-migrate + # service) -- the one-shot schema-migration Job. + migrateImage: + repository: sentry-clickhouse-migrate + tag: latest + +postgres: + image: + repository: postgres + tag: 16-alpine + persistence: + size: 10Gi + resources: {} + password: "" + auditWriterPassword: "" + # Built from ./metadata (docker-compose.yml's metadata-migrate + # service) -- the one-shot schema-migration Job. + migrateImage: + repository: sentry-metadata-migrate + tag: latest + +ingest: + image: + repository: sentry-ingest + tag: latest + replicas: 1 + resources: {} + # mTLS server cert/key/CA -- see hack/dev-certs/generate.sh for the + # dev equivalent of what this Secret must contain + # (server.pem/server-key.pem/ca.pem) in a real deployment. Unlike + # docker-compose.yml's bind-mounted ./hack/dev-certs/out, a cluster + # deployment supplies this as a real Secret -- named here, not + # generated by this chart (cert issuance is out of scope, same + # "boring, well-understood" preference as everywhere else in this + # repo -- use cert-manager or an equivalent, don't hand-roll it here). + tlsSecretName: "" + +search: + image: + repository: sentry-search + tag: latest + # Pinned to 1: search consumes the same Redpanda topic ingest's + # consumer does with its own offset tracking (see search/README.md). + # A second replica would form a second, independent consumer instance + # against the same partitions with no coordination -- correctness, + # not just resource waste, is the reason this isn't a `replicas` knob + # yet. Matches CLAUDE.md's Phase 4 non-goal: "no general multi-cluster + # orchestration." + replicas: 1 + resources: {} + persistence: + size: 20Gi + +api: + image: + repository: sentry-api + tag: latest + replicas: 2 + resources: {} + +alerting: + image: + repository: sentry-alerting + tag: latest + # Pinned to 1 for the same reason as search: rulestore.ClaimDueRules + # has no leader-election/partitioning story for multiple evaluator + # replicas yet -- two would both try to claim and evaluate the same + # due rules. Named explicitly rather than silently defaulted, since + # it's the kind of knob someone reasonably expects to just work. + replicas: 1 + resources: {} + # See templates/alerting.yaml's comment -- only meaningful when + # enterprise.enabled is true. Empty by default. + apiServiceToken: "" + +web: + image: + repository: sentry-web + tag: latest + replicas: 2 + resources: {} + # NOT wired to any Deployment env var -- web is a static SvelteKit + # build (adapter-static, see web/package.json), and VITE_API_BASE_URL/ + # VITE_ALERTING_API_BASE_URL/VITE_ENTERPRISE_AUTH_BASE_URL are baked in + # at *image build time* (docker-compose.yml's web.build.args), not + # read at container runtime. Deploying this chart into a real cluster + # means rebuilding the web image with these three build args pointed + # at wherever api/alerting/enterprise-auth are actually reachable from + # a browser (an Ingress host, a LoadBalancer IP, etc.) -- this section + # exists to document that requirement, not because the chart can act + # on it. + builtWithApiBaseURL: "http://localhost:8080" + builtWithAlertingApiBaseURL: "http://localhost:8081" + builtWithEnterpriseAuthBaseURL: "http://localhost:8082" + +# enterprise-auth (commercial license) + the tenant-operator that +# reconciles the Tenant CRD -- both off by default, matching +# docker-compose.yml's own "included, not wired into enforcement by +# default" stance (see its enterprise-auth service comment) and +# enterprise/README.md's "Status" section on what's built vs. deferred. +enterprise: + enabled: false + image: + repository: sentry-enterprise-auth + tag: latest + replicas: 1 + resources: {} + # Leave empty to auto-generate (>= 32 bytes) and persist across + # upgrades -- see templates/secrets.yaml. + sessionSigningKey: "" + oidc: + issuerURL: "" + clientID: "" + clientSecret: "" + redirectURL: "" + saml: + entityID: "" + acsURL: "" + idpMetadataURL: "" + +# Installs deploy/operator (the Tenant CRD controller) alongside this +# chart. Only meaningful when enterprise.enabled is also true -- +# gated on that, not a separate flag, since a Tenant CR with no +# enterprise-auth deployed to consume its Secret has nothing to do. +tenantOperator: + enabled: false + image: + repository: sentry-tenant-operator + tag: latest + resources: {} + +# One entry per tenant to provision -- rendered as Tenant CRs +# (templates/tenants.yaml), reconciled by the tenant-operator into a +# per-tenant ClickHouse credential Secret. See +# deploy/operator/internal/controller/tenant_controller.go's doc comment +# for exactly what that does and doesn't set up. Empty by default; a +# real two-tenant deployment (Phase 4's exit criteria) sets e.g.: +# tenants: +# - name: acme +# displayName: "Acme Corp" +# - name: globex +# displayName: "Globex Corporation" +tenants: [] diff --git a/deploy/operator/Dockerfile b/deploy/operator/Dockerfile new file mode 100644 index 0000000..eb96f02 --- /dev/null +++ b/deploy/operator/Dockerfile @@ -0,0 +1,12 @@ +# Same shape as every other Go service's Dockerfile in this repo +# (alerting/Dockerfile, enterprise/Dockerfile) -- context is +# deploy/operator/ itself, no /proto dependency. +# docker build -f deploy/operator/Dockerfile -t sentry-tenant-operator deploy/operator/ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/tenant-operator ./cmd/tenant-operator + +FROM gcr.io/distroless/static-debian12 +COPY --from=builder /out/tenant-operator /tenant-operator +ENTRYPOINT ["/tenant-operator"] diff --git a/deploy/operator/README.md b/deploy/operator/README.md new file mode 100644 index 0000000..c078ee6 --- /dev/null +++ b/deploy/operator/README.md @@ -0,0 +1,81 @@ +# deploy/operator + +A small `controller-runtime` Operator managing one CRD: `Tenant` +(`sentry.io/v1alpha1`). See `internal/controller/tenant_controller.go`'s +doc comment for exactly what it reconciles and -- just as importantly -- +what it deliberately doesn't (no ClickHouse calls, no Tantivy filesystem +access, no `enterprise/internal/rbacstore` wiring; those are +`enterprise/internal/tenantprovision`, still unbuilt). + +## Not kubebuilder-scaffolded + +No `kubebuilder`/`controller-gen` binary was available in this +environment, so this package is hand-written rather than generated: + +- `api/v1alpha1/zz_generated.deepcopy.go` -- normally `controller-gen + object` output; hand-written here, covered by + `api/v1alpha1/api_test.go`'s round-trip tests (mutate a copy, assert + the original is untouched -- exactly the class of bug a hand-written + `DeepCopy` is prone to). +- `config/crd/sentry.io_tenants.yaml` -- normally `controller-gen crd` + output from the `+kubebuilder:validation:*` markers on + `api/v1alpha1/tenant_types.go`; hand-written here and only as strong as + keeping the two in sync by hand. Validated by strict-unmarshaling it + into the real `k8s.io/apiextensions-apiserver` Go type (see + `/deploy/README.md`'s verification section) -- catches YAML/structural + mistakes, not a drift between the CRD's field *descriptions* and the + Go doc comments. +- `+kubebuilder:rbac` markers on `internal/controller/tenant_controller.go` + are present as documentation/intent (matching kubebuilder convention) + but were never run through `controller-gen rbac` -- the actual + ClusterRole is hand-written in + `/deploy/helm/sentry/templates/tenant-operator.yaml`, kept in sync with + those markers by hand, same caveat as the CRD above. + +## Layout + +``` +api/v1alpha1/ Tenant, TenantSpec, TenantStatus -- the CRD's Go types +internal/controller/ TenantReconciler -- see its doc comment +cmd/tenant-operator/ main.go -- manager setup, matches every other + service's cmd//main.go convention in this repo +config/crd/ hand-written CRD YAML (see above) +``` + +## Building & testing + +```sh +go build ./... +go vet ./... +go test ./... +``` + +Tests use `sigs.k8s.io/controller-runtime/pkg/client/fake`, not +`envtest` -- `envtest` needs a real `kube-apiserver`/`etcd` binary pair +(`setup-envtest`) not available in this environment. The fake client +exercises real reconcile logic (object CRUD, owner references, status +writes) but not anything a real apiserver does for you (admission, +garbage collection, watch-triggered re-reconciliation) -- see +`internal/controller/tenant_controller_test.go`'s doc comment. + +```sh +docker build -f Dockerfile -t sentry-tenant-operator . # context is deploy/operator/, not the repo root +``` + +Not verified in this session -- see `/deploy/README.md`. + +## Trying it against a real cluster + +```sh +kubectl apply -f config/crd/sentry.io_tenants.yaml +kubectl apply -f - <<'EOF' +apiVersion: sentry.io/v1alpha1 +kind: Tenant +metadata: + name: acme +spec: + displayName: "Acme Corp" +EOF +kubectl get tenant acme -o yaml # status.phase should reach Active +kubectl get secret sentry-tenant-acme-clickhouse -o yaml +``` diff --git a/deploy/operator/api/v1alpha1/api_test.go b/deploy/operator/api/v1alpha1/api_test.go new file mode 100644 index 0000000..30ced6b --- /dev/null +++ b/deploy/operator/api/v1alpha1/api_test.go @@ -0,0 +1,71 @@ +// Exercises the hand-written DeepCopy methods in zz_generated.deepcopy.go +// -- see that file's doc comment for why these aren't controller-gen +// output here. A DeepCopy that accidentally shares a slice/map with the +// original is a real, easy-to-introduce bug (client-go relies on +// DeepCopyObject returning something safe to mutate independently), so +// these tests mutate the copy and assert the original is unaffected. +package v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestTenantDeepCopyIsIndependent(t *testing.T) { + orig := &Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "acme", Labels: map[string]string{"a": "1"}}, + Spec: TenantSpec{DisplayName: "Acme", Suspended: false}, + Status: TenantStatus{ + Phase: PhaseActive, + Conditions: []metav1.Condition{ + {Type: ConditionReady, Status: metav1.ConditionTrue, Reason: "x"}, + }, + }, + } + + cp := orig.DeepCopy() + cp.Spec.DisplayName = "Changed" + cp.Status.Conditions[0].Reason = "changed" + cp.Labels["a"] = "changed" + + if orig.Spec.DisplayName != "Acme" { + t.Fatalf("mutating the copy's Spec affected the original: %q", orig.Spec.DisplayName) + } + if orig.Status.Conditions[0].Reason != "x" { + t.Fatalf("mutating the copy's Conditions affected the original: %q", orig.Status.Conditions[0].Reason) + } + // Labels comes from metav1.ObjectMeta.DeepCopyInto, which this + // package doesn't implement itself -- this assertion is really + // checking that Tenant.DeepCopyInto actually calls + // ObjectMeta.DeepCopyInto rather than doing a shallow `out.ObjectMeta + // = in.ObjectMeta`. + if orig.Labels["a"] != "1" { + t.Fatalf("mutating the copy's Labels affected the original: %q", orig.Labels["a"]) + } +} + +func TestTenantDeepCopyObjectPreservesData(t *testing.T) { + orig := &Tenant{ObjectMeta: metav1.ObjectMeta{Name: "acme"}, Spec: TenantSpec{DisplayName: "Acme"}} + obj := orig.DeepCopyObject() + cp, ok := obj.(*Tenant) + if !ok { + t.Fatalf("DeepCopyObject returned %T, want *Tenant", obj) + } + if cp.Name != "acme" || cp.Spec.DisplayName != "Acme" { + t.Fatalf("unexpected copy: %+v", cp) + } +} + +func TestTenantListDeepCopyIsIndependent(t *testing.T) { + orig := &TenantList{Items: []Tenant{ + {ObjectMeta: metav1.ObjectMeta{Name: "acme"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "globex"}}, + }} + cp := orig.DeepCopy() + cp.Items[0].Name = "changed" + + if orig.Items[0].Name != "acme" { + t.Fatalf("mutating the copy's Items affected the original: %q", orig.Items[0].Name) + } +} diff --git a/deploy/operator/api/v1alpha1/groupversion_info.go b/deploy/operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..4f77b07 --- /dev/null +++ b/deploy/operator/api/v1alpha1/groupversion_info.go @@ -0,0 +1,27 @@ +// Package v1alpha1 contains the Tenant API's Go types -- kubebuilder's +// standard api// layout, hand-written rather than scaffolded +// (no kubebuilder/controller-gen binary available in this environment; +// see /home/john/Projects/sentry/deploy/README.md's verification +// section for what that means for this package specifically: it's real, +// compiling, unit-tested Go code, never reconciled against a live +// cluster). +// +// +kubebuilder:object:generate=true +// +groupName=sentry.io +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group sentry.io, version v1alpha1. + GroupVersion = schema.GroupVersion{Group: "sentry.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/deploy/operator/api/v1alpha1/tenant_types.go b/deploy/operator/api/v1alpha1/tenant_types.go new file mode 100644 index 0000000..9879f50 --- /dev/null +++ b/deploy/operator/api/v1alpha1/tenant_types.go @@ -0,0 +1,117 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TenantPhase mirrors the provisioning state machine from +// /docs/phase-4-isolation-design.md: every tenant-resolution path +// elsewhere must refuse to serve a tenant not in PhaseActive, checked +// server-side (today, against enterprise/internal/rbacstore's tenants +// table -- this CR is a K8s-native *view* of the same state machine at +// the deployment-topology layer, not a second source of truth. Reconciling +// the two together is exactly the kind of tenant-provisioning wiring +// named as deferred in /docs/phase-4-runbook.md's task 6 section: today +// this operator only manages the K8s-side artifact (a per-tenant +// ClickHouse credential Secret + a ConfigMap recording the tenant's +// database name/index path), not the actual `CREATE DATABASE`/`CREATE +// USER`/`GRANT` calls against ClickHouse -- that's +// enterprise/internal/tenantprovision, still unbuilt. +type TenantPhase string + +const ( + PhaseProvisioning TenantPhase = "Provisioning" + PhaseActive TenantPhase = "Active" + PhaseSuspended TenantPhase = "Suspended" + PhaseDeprovisioning TenantPhase = "Deprovisioning" +) + +// TenantSpec is the desired state -- an operator/admin's intent, set via +// `kubectl apply` or (per the Helm chart's templates/tenants.yaml) a +// values.yaml `tenants:` entry. +type TenantSpec struct { + // DisplayName is human-readable only -- the Tenant object's own Name + // (metav1.ObjectMeta) is the stable identifier, matching + // rbacstore.Tenant.ID's "slug, not UUID" reasoning (see + // /docs/phase-4-rbac-design.md's schema section) so this CRD's name + // can be the same string used elsewhere (ClickHouse database name, + // rbacstore tenant ID) without a translation layer. + // +kubebuilder:validation:Required + DisplayName string `json:"displayName"` + + // Suspended is the admin-facing lever for the Suspended phase (e.g. + // an incident-response or billing action) -- distinct from + // Provisioning/Deprovisioning, which the controller drives from + // object lifecycle (creation, deletion), not from this field. + // +optional + Suspended bool `json:"suspended,omitempty"` +} + +// TenantStatus is observed state -- only the controller writes this. +type TenantStatus struct { + // +optional + Phase TenantPhase `json:"phase,omitempty"` + + // ClickHouseDatabaseName is derived (today: same as the Tenant's own + // Name) rather than settable in Spec -- see task 2's design: no + // tenant traffic authenticates as ClickHouse's `default` user, and a + // database name that could diverge from the tenant identifier is a + // bookkeeping foot-gun this type avoids by construction. + // +optional + ClickHouseDatabaseName string `json:"clickHouseDatabaseName,omitempty"` + + // ClickHouseSecretRef names the Secret (same namespace) holding this + // tenant's dedicated, narrowly-granted ClickHouse credentials -- see + // tenant_controller.go's reconcileSecret. Never the cluster-wide + // CLICKHOUSE_PASSWORD docker-compose.yml uses today. + // +optional + ClickHouseSecretRef string `json:"clickHouseSecretRef,omitempty"` + + // TantivyIndexPath is this tenant's index directory under the shared + // search-index PVC -- see /docs/phase-4-isolation-design.md's + // Tantivy index-per-tenant section. + // +optional + TantivyIndexPath string `json:"tantivyIndexPath,omitempty"` + + // +optional + // +patchMergeKey=type + // +patchStrategy=merge + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// ConditionReady is the one condition type this controller sets today -- +// more (e.g. ClickHouseProvisioned, once internal/tenantprovision +// exists) are additive future work, not a breaking change to this type. +const ConditionReady = "Ready" + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Tenant is the K8s-native representation of one Sentry tenant's +// deployment-topology state -- see this file's package-level doc +// comment for what it does and does not manage today. +type Tenant struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TenantSpec `json:"spec,omitempty"` + Status TenantStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// TenantList is a list of Tenant. +type TenantList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Tenant `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Tenant{}, &TenantList{}) +} diff --git a/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go b/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..ad3fcb6 --- /dev/null +++ b/deploy/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,92 @@ +// Hand-written, not `controller-gen object:headerFile=...` generated -- +// no controller-gen binary available in this environment (see +// groupversion_info.go's doc comment). Kept under the conventional +// zz_generated.deepcopy.go name so its purpose is recognizable, and +// covered by api_test.go's round-trip tests since a hand-written +// DeepCopy is exactly the kind of code a typo silently breaks. +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func (in *TenantSpec) DeepCopy() *TenantSpec { + if in == nil { + return nil + } + out := new(TenantSpec) + *out = *in + return out +} + +func (in *TenantStatus) DeepCopyInto(out *TenantStatus) { + *out = *in + if in.Conditions != nil { + out.Conditions = make([]metav1.Condition, len(in.Conditions)) + for i := range in.Conditions { + in.Conditions[i].DeepCopyInto(&out.Conditions[i]) + } + } +} + +func (in *TenantStatus) DeepCopy() *TenantStatus { + if in == nil { + return nil + } + out := new(TenantStatus) + in.DeepCopyInto(out) + return out +} + +func (in *Tenant) DeepCopyInto(out *Tenant) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +func (in *Tenant) DeepCopy() *Tenant { + if in == nil { + return nil + } + out := new(Tenant) + in.DeepCopyInto(out) + return out +} + +func (in *Tenant) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +func (in *TenantList) DeepCopyInto(out *TenantList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]Tenant, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} + +func (in *TenantList) DeepCopy() *TenantList { + if in == nil { + return nil + } + out := new(TenantList) + in.DeepCopyInto(out) + return out +} + +func (in *TenantList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/deploy/operator/cmd/tenant-operator/main.go b/deploy/operator/cmd/tenant-operator/main.go new file mode 100644 index 0000000..795c270 --- /dev/null +++ b/deploy/operator/cmd/tenant-operator/main.go @@ -0,0 +1,81 @@ +// Command tenant-operator runs the Tenant CRD controller (see +// internal/controller/tenant_controller.go's doc comment for exactly +// what it does and doesn't manage). Same "cmd//main.go" +// convention as every other Go service in this repo +// (api/cmd/api, enterprise/cmd/enterprise-auth), rather than +// kubebuilder's newer default of a bare cmd/main.go. +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + sentryv1alpha1 "github.com/sentry/sentry/deploy/operator/api/v1alpha1" + "github.com/sentry/sentry/deploy/operator/internal/controller" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(sentryv1alpha1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr, probeAddr string + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the health probe endpoint binds to.") + opts := zap.Options{Development: false} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + logger := zap.New(zap.UseFlagOptions(&opts)) + ctrl.SetLogger(logger) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: server.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + // A single tenant-operator replica reconciling cluster-wide state + // is enough at this scope (see internal/controller's doc comment + // on what it does and doesn't manage) -- leader election matters + // once a second replica could double-generate a Secret, not + // before. + LeaderElection: false, + }) + if err != nil { + logger.Error(err, "unable to start manager") + os.Exit(1) + } + + if err := (&controller.TenantReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to create controller", "controller", "Tenant") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up ready check") + os.Exit(1) + } + + logger.Info("starting tenant-operator") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/deploy/operator/config/crd/sentry.io_tenants.yaml b/deploy/operator/config/crd/sentry.io_tenants.yaml new file mode 100644 index 0000000..5120cf0 --- /dev/null +++ b/deploy/operator/config/crd/sentry.io_tenants.yaml @@ -0,0 +1,93 @@ +# Hand-written, not `controller-gen crd` output -- see +# api/v1alpha1/groupversion_info.go's doc comment. Kept in sync with +# api/v1alpha1/tenant_types.go by hand; api/v1alpha1/api_test.go's +# round-trip tests catch a Go/YAML drift in the *shape* of the types, +# but not a drift in this file's field descriptions/validation rules -- +# review both together when either changes. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: tenants.sentry.io +spec: + group: sentry.io + names: + kind: Tenant + listKind: TenantList + plural: tenants + singular: tenant + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Phase + type: string + jsonPath: .status.phase + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + description: >- + Tenant is the K8s-native representation of one Sentry tenant's + deployment-topology state -- see + deploy/operator/internal/controller/tenant_controller.go's doc + comment for what the controller does and does not manage. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: [displayName] + properties: + displayName: + type: string + description: Human-readable only -- the object's own metadata.name is the stable identifier. + suspended: + type: boolean + description: Admin-facing lever for the Suspended phase. + default: false + status: + type: object + properties: + phase: + type: string + enum: [Provisioning, Active, Suspended, Deprovisioning] + clickHouseDatabaseName: + type: string + clickHouseSecretRef: + type: string + tantivyIndexPath: + type: string + observedGeneration: + type: integer + format: int64 + conditions: + type: array + items: + type: object + required: [type, status] + properties: + type: + type: string + status: + type: string + enum: ["True", "False", "Unknown"] + reason: + type: string + message: + type: string + observedGeneration: + type: integer + format: int64 + lastTransitionTime: + type: string + format: date-time diff --git a/deploy/operator/go.mod b/deploy/operator/go.mod new file mode 100644 index 0000000..1d9cb26 --- /dev/null +++ b/deploy/operator/go.mod @@ -0,0 +1,67 @@ +module github.com/sentry/sentry/deploy/operator + +go 1.25.0 + +require ( + k8s.io/api v0.31.0 + k8s.io/apimachinery v0.31.0 + k8s.io/client-go v0.31.0 + sigs.k8s.io/controller-runtime v0.19.3 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.31.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/deploy/operator/go.sum b/deploy/operator/go.sum new file mode 100644 index 0000000..b83e703 --- /dev/null +++ b/deploy/operator/go.sum @@ -0,0 +1,192 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +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/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= +k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= +k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= +k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= +k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= +k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= +k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= +sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/deploy/operator/internal/controller/tenant_controller.go b/deploy/operator/internal/controller/tenant_controller.go new file mode 100644 index 0000000..0320376 --- /dev/null +++ b/deploy/operator/internal/controller/tenant_controller.go @@ -0,0 +1,186 @@ +// Package controller reconciles the Tenant CRD (api/v1alpha1) into the +// K8s-native artifacts task 2/CLAUDE.md's Phase 4 exit criteria calls +// for: "real per-tenant secret management (replacing today's single +// shared CLICKHOUSE_PASSWORD)" -- see docker-compose.yml's +// CLICKHOUSE_PASSWORD comment for what that shared-secret shape looks +// like today. +// +// What this reconciler does NOT do, named explicitly rather than +// implied: it never calls ClickHouse (no CREATE DATABASE/CREATE USER/ +// GRANT), never touches the Tantivy index filesystem, and never talks to +// enterprise/internal/rbacstore. Those are enterprise/internal/ +// tenantprovision's job -- unbuilt, per the task 5 summary. This +// controller's job stops at "does a K8s Secret with this tenant's +// ClickHouse credentials exist, and does the Tenant's status reflect +// that" -- the deployment-topology half of tenant provisioning, not the +// database-side half. A Tenant reaching PhaseActive here is NOT the same +// claim as rbacstore's tenants.status='active' (the actual gate every +// tenant-resolution code path checks per +// /docs/phase-4-isolation-design.md) -- reconciling those two into one +// state machine is exactly the kind of follow-up work +// /docs/phase-4-runbook.md's task 6 section names as deferred. +package controller + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + sentryv1alpha1 "github.com/sentry/sentry/deploy/operator/api/v1alpha1" +) + +// TenantReconciler reconciles a Tenant object. +type TenantReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// clickHouseSecretName is deterministic from the tenant name -- never +// randomly suffixed -- so a re-run of Reconcile (or a controller +// restart) finds the same Secret it created before, rather than losing +// track of it and creating a second one. +func clickHouseSecretName(tenant *sentryv1alpha1.Tenant) string { + return fmt.Sprintf("sentry-tenant-%s-clickhouse", tenant.Name) +} + +// tantivyIndexPath mirrors /docs/phase-4-isolation-design.md's Tantivy +// section: one directory per tenant under the shared search-index +// volume (search-index-data in docker-compose.yml; a PVC in the Helm +// chart -- see deploy/helm/sentry/templates/search-deployment.yaml). +func tantivyIndexPath(tenant *sentryv1alpha1.Tenant) string { + return "/var/lib/sentry-search/tenants/" + tenant.Name +} + +// generatePassword returns a 32-byte random value, base64-encoded -- +// same "narrowly-granted, per-tenant, never the shared default user" +// framing as /docs/phase-4-isolation-design.md's ClickHouse section, +// applied to how the credential itself is generated (crypto/rand, not +// math/rand -- this becomes a real ClickHouse user's password once +// internal/tenantprovision consumes it). +func generatePassword() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generating password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// +kubebuilder:rbac:groups=sentry.io,resources=tenants,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=sentry.io,resources=tenants/status,verbs=get;update;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete + +func (r *TenantReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var tenant sentryv1alpha1.Tenant + if err := r.Get(ctx, req.NamespacedName, &tenant); err != nil { + if apierrors.IsNotFound(err) { + // Deleted -- owned Secret is garbage-collected by K8s via + // its OwnerReference (set in reconcileSecret below), nothing + // else to clean up at this layer. See this file's package + // doc comment: real deprovisioning (revoking ClickHouse + // grants) isn't this controller's job. + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("getting tenant: %w", err) + } + + secretName, err := r.reconcileSecret(ctx, &tenant) + if err != nil { + logger.Error(err, "reconciling clickhouse secret") + return ctrl.Result{}, err + } + + desiredPhase := sentryv1alpha1.PhaseActive + if tenant.Spec.Suspended { + desiredPhase = sentryv1alpha1.PhaseSuspended + } + + tenant.Status.ClickHouseDatabaseName = tenant.Name + tenant.Status.ClickHouseSecretRef = secretName + tenant.Status.TantivyIndexPath = tantivyIndexPath(&tenant) + tenant.Status.Phase = desiredPhase + tenant.Status.ObservedGeneration = tenant.Generation + meta.SetStatusCondition(&tenant.Status.Conditions, metav1.Condition{ + Type: sentryv1alpha1.ConditionReady, + Status: metav1.ConditionTrue, + Reason: "SecretReconciled", + Message: fmt.Sprintf("ClickHouse credential secret %q is present", secretName), + ObservedGeneration: tenant.Generation, + }) + + if err := r.Status().Update(ctx, &tenant); err != nil { + return ctrl.Result{}, fmt.Errorf("updating tenant status: %w", err) + } + + return ctrl.Result{}, nil +} + +// reconcileSecret creates the tenant's ClickHouse credential Secret if +// it doesn't already exist. Deliberately never updates an existing +// Secret's password -- rotating a live tenant's ClickHouse credential +// out from under it (without first updating the ClickHouse-side grant, +// which this controller doesn't do) would just break every open +// connection for no benefit; credential rotation is real future work +// that needs to be coordinated with internal/tenantprovision, not +// something this reconcile loop can safely do alone. +func (r *TenantReconciler) reconcileSecret(ctx context.Context, tenant *sentryv1alpha1.Tenant) (string, error) { + name := clickHouseSecretName(tenant) + + var existing corev1.Secret + err := r.Get(ctx, types.NamespacedName{Namespace: tenant.Namespace, Name: name}, &existing) + if err == nil { + return name, nil + } + if !apierrors.IsNotFound(err) { + return "", fmt.Errorf("getting secret: %w", err) + } + + password, err := generatePassword() + if err != nil { + return "", err + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: tenant.Namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "sentry-tenant-operator", + "sentry.io/tenant": tenant.Name, + }, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + "username": "tenant_" + tenant.Name, + "password": password, + "database": tenant.Name, + }, + } + if err := controllerutil.SetControllerReference(tenant, secret, r.Scheme); err != nil { + return "", fmt.Errorf("setting owner reference: %w", err) + } + if err := r.Create(ctx, secret); err != nil { + return "", fmt.Errorf("creating secret: %w", err) + } + return name, nil +} + +func (r *TenantReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&sentryv1alpha1.Tenant{}). + Owns(&corev1.Secret{}). + Complete(r) +} diff --git a/deploy/operator/internal/controller/tenant_controller_test.go b/deploy/operator/internal/controller/tenant_controller_test.go new file mode 100644 index 0000000..57ea6f5 --- /dev/null +++ b/deploy/operator/internal/controller/tenant_controller_test.go @@ -0,0 +1,155 @@ +// Tests use controller-runtime's fake client (sigs.k8s.io/ +// controller-runtime/pkg/client/fake), not envtest -- envtest needs a +// real kube-apiserver/etcd binary pair (setup-envtest) that isn't +// available in this environment (see package doc comment and +// deploy/README.md's verification section). A fake client exercises +// Reconcile's actual logic (object CRUD, owner references, status +// writes) against an in-memory tracker; what it can't exercise is +// anything a real apiserver would do for you (defaulting, admission, +// actual garbage collection of owned objects, watch-triggered +// re-reconciliation) -- so passing here is real signal about this +// reconciler's logic, not proof it behaves correctly against a live +// cluster. +package controller + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + sentryv1alpha1 "github.com/sentry/sentry/deploy/operator/api/v1alpha1" +) + +func newFakeReconciler(t *testing.T, objs ...client.Object) *TenantReconciler { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding corev1 to scheme: %v", err) + } + if err := sentryv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("adding sentryv1alpha1 to scheme: %v", err) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&sentryv1alpha1.Tenant{}). + Build() + return &TenantReconciler{Client: fakeClient, Scheme: scheme} +} + +func testTenant(name string, suspended bool) *sentryv1alpha1.Tenant { + return &sentryv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: sentryv1alpha1.TenantSpec{DisplayName: name, Suspended: suspended}, + } +} + +func TestReconcileCreatesSecretAndSetsActivePhase(t *testing.T) { + tenant := testTenant("acme", false) + r := newFakeReconciler(t, tenant) + ctx := context.Background() + + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "acme", Namespace: "default"}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &secret); err != nil { + t.Fatalf("expected a ClickHouse secret to be created: %v", err) + } + if secret.StringData["username"] != "tenant_acme" || secret.StringData["database"] != "acme" { + t.Fatalf("unexpected secret data: %+v", secret.StringData) + } + if secret.StringData["password"] == "" { + t.Fatal("expected a non-empty generated password") + } + if len(secret.OwnerReferences) != 1 || secret.OwnerReferences[0].Name != "acme" { + t.Fatalf("expected secret to be owned by the Tenant, got %+v", secret.OwnerReferences) + } + + var got sentryv1alpha1.Tenant + if err := r.Get(ctx, types.NamespacedName{Name: "acme", Namespace: "default"}, &got); err != nil { + t.Fatalf("getting tenant: %v", err) + } + if got.Status.Phase != sentryv1alpha1.PhaseActive { + t.Fatalf("Phase = %q, want Active", got.Status.Phase) + } + if got.Status.ClickHouseDatabaseName != "acme" { + t.Fatalf("ClickHouseDatabaseName = %q, want acme", got.Status.ClickHouseDatabaseName) + } + if got.Status.ClickHouseSecretRef != "sentry-tenant-acme-clickhouse" { + t.Fatalf("ClickHouseSecretRef = %q", got.Status.ClickHouseSecretRef) + } + if got.Status.TantivyIndexPath != "/var/lib/sentry-search/tenants/acme" { + t.Fatalf("TantivyIndexPath = %q", got.Status.TantivyIndexPath) + } +} + +func TestReconcileSuspendedSetsSuspendedPhaseButKeepsSecret(t *testing.T) { + tenant := testTenant("acme", true) + r := newFakeReconciler(t, tenant) + ctx := context.Background() + + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "acme", Namespace: "default"}}); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + var got sentryv1alpha1.Tenant + if err := r.Get(ctx, types.NamespacedName{Name: "acme", Namespace: "default"}, &got); err != nil { + t.Fatalf("getting tenant: %v", err) + } + if got.Status.Phase != sentryv1alpha1.PhaseSuspended { + t.Fatalf("Phase = %q, want Suspended", got.Status.Phase) + } + + // A suspended tenant's credential Secret is NOT deleted -- suspension + // is reversible and this controller doesn't manage ClickHouse-side + // grants, so there's nothing at this layer to actually enforce + // suspension; deleting the Secret would just be theater. + var secret corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &secret); err != nil { + t.Fatalf("expected secret to still exist for a suspended tenant: %v", err) + } +} + +func TestReconcileIsIdempotentAndNeverRotatesPassword(t *testing.T) { + tenant := testTenant("acme", false) + r := newFakeReconciler(t, tenant) + ctx := context.Background() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "acme", Namespace: "default"}} + + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("first Reconcile: %v", err) + } + var first corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &first); err != nil { + t.Fatalf("getting secret after first reconcile: %v", err) + } + + if _, err := r.Reconcile(ctx, req); err != nil { + t.Fatalf("second Reconcile: %v", err) + } + var second corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &second); err != nil { + t.Fatalf("getting secret after second reconcile: %v", err) + } + + if first.StringData["password"] != second.StringData["password"] { + t.Fatal("password changed across a re-reconcile -- would break every live connection for this tenant") + } +} + +func TestReconcileMissingTenantIsNoOp(t *testing.T) { + r := newFakeReconciler(t) + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "does-not-exist", Namespace: "default"}}) + if err != nil { + t.Fatalf("Reconcile on a missing tenant should be a no-op, got error: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5e4e475..f7d4163 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,11 @@ services: POSTGRES_USER: "sentry" POSTGRES_PASSWORD: "sentry-dev-only" POSTGRES_DATABASE: "sentry_metadata" + # Password for the restricted audit_writer Postgres role (Phase 4 + # task 4) -- INSERT+SELECT only on audit_log, never UPDATE/DELETE, + # via its own connection pool distinct from the shared "sentry" + # role every other store uses. See /docs/phase-4-isolation-design.md. + AUDIT_WRITER_PASSWORD: "audit-writer-dev-only" ingest: build: @@ -231,6 +236,34 @@ services: timeout: 5s retries: 30 + # Commercial-license SSO/RBAC service (Phase 4) -- see + # /docs/phase-4-isolation-design.md and enterprise/README.md. Included + # here so it can be built/run/curled like every other service, but + # deliberately NOT wired into api's ENTERPRISE_AUTH_URL or alerting's + # API_SERVICE_TOKEN below: turning that on makes every /query and + # /dashboards request require a valid session/service token, and there + # is no OIDC/SAML login flow built yet to issue a human one (see + # enterprise/cmd/enterprise-auth/main.go's doc comment) -- flipping it + # on by default would break the web UI and sentryctl with no way to + # log in. See enterprise/README.md for how to turn enforcement on for + # manual testing (mint a service token, set the two env vars, restart). + enterprise-auth: + build: + context: enterprise + dockerfile: Dockerfile + container_name: sentry-enterprise-auth + ports: + - "8082:8082" + environment: + # Dev-only, same framing as CLICKHOUSE_PASSWORD above -- not a real + # secret. Must be at least 32 bytes (see internal/config.Load). + ENTERPRISE_SESSION_SIGNING_KEY: "sentry-dev-only-session-signing-key-32bytes+" + healthcheck: + test: ["CMD", "/enterprise-auth", "-healthcheck"] + interval: 5s + timeout: 5s + retries: 30 + web: build: context: web @@ -241,10 +274,12 @@ services: # network's service DNS names. VITE_API_BASE_URL: "http://localhost:8080" VITE_ALERTING_API_BASE_URL: "http://localhost:8081" + VITE_ENTERPRISE_AUTH_BASE_URL: "http://localhost:8082" container_name: sentry-web depends_on: - api - alerting + - enterprise-auth ports: - "3000:3000" diff --git a/docs/architecture.md b/docs/architecture.md index d132c1e..8e80f2a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,9 +1,13 @@ # Sentry Architecture -> **Status:** Draft, Phase 0 scope. Written from the project constraints and -> task list at kickoff, not transcribed from a pre-existing spec. Treat as a -> starting point to correct, not a settled design — flag anything that -> doesn't match your intent before implementation leans on it further. +> **Status:** Updated through Phase 4. The component map/diagram below is +> still the Phase 0 request path (agent → ingest → ClickHouse → api → +> web) — it was never redrawn for the full-text search, dashboards/ +> alerting, or enterprise/ additions; see each phase's runbook +> (`/docs/phase-N-runbook.md`) for what was actually verified when it +> shipped. The component responsibilities table and the sections below +> the diagram are kept current. Phase 0's original framing ("draft, +> correct as needed") still applies to anything not yet built. ## Mission @@ -53,44 +57,107 @@ one instead of deferring it, and keeps Kafka credentials off the edge agent. schema; schema-on-read fallback for unstructured/raw text that doesn't fit the structured columns (captured via the `Map` column and/or a raw passthrough field). +- **Postgres** (Phase 3) holds control-plane config only — dashboards, + panels, notification targets, alert rules/state, delivery log, and + (Phase 4) tenants/users/tenant_memberships/audit_log. Never log data; + ClickHouse/Tantivy stay the only place a log record itself lives. See + `/docs/phase-3-dashboard-design.md` for why ClickHouse's MergeTree + family isn't a fit for this (no real row-level locking/transactional + read-modify-write). This split is not to be changed without discussion — see CLAUDE.md. -## Component responsibilities (Phase 0) +## Component responsibilities | Component | Responsibility | |---|---| -| `agent` (Rust, musl) | Tail a log file or read journald; parse RFC 5424 syslog with raw passthrough fallback; batch; ship via gRPC/mTLS to `ingest`. | -| `proto` | Shared `.proto` contracts for the agent↔ingest gRPC service, versioned independently of either component. | +| `agent` (Rust, musl) | Tail a log file or read journald; parse RFC 5424 syslog with raw passthrough fallback; batch; ship via gRPC/mTLS to `ingest`. Windows (ETW/Event Log) code exists but is unverified on real Windows hardware — see `/agent/README.md`. | +| `proto` | Shared `.proto` contracts for agent↔ingest and api↔search gRPC, versioned independently of either side. | | `transport` | Redpanda docker-compose + topic provisioning scripts. No application code. | -| `ingest` (Go) | gRPC server accepting agent connections; produces normalized OTel-log-like records to Redpanda; separate consumer reads from Redpanda and batch-writes to ClickHouse. | +| `ingest` (Go) | gRPC server accepting agent connections; produces normalized OTel-log-like records to Redpanda; separate consumer reads from Redpanda and batch-writes to ClickHouse. No tenant concept — every record lands in the one shared `logs` table regardless of source (see "Tenant isolation" below). | | `storage` | ClickHouse schema migrations + docker-compose for local/homelab. | -| `api` (Go) | gRPC + REST gateway. Phase 0: one crude `POST /query` endpoint, SELECT-only, proxying to ClickHouse. Real SPL-like query layer is Phase 2. | -| `web` (SvelteKit) | Single page: SQL text box, submit, results table. No auth, no styling polish. | -| `cli` (`sentryctl`) | Stub. Single `ping` command for now. | -| `deploy` | Helm charts, k8s manifests. Stubbed in Phase 0; docker-compose is the real local/dev path. | +| `search` (Rust, Phase 1) | Consumes the same Redpanda topic `ingest` does (own offset tracking), builds a Tantivy full-text index over `message`, serves matches over gRPC. One shared index for every tenant today — see "Tenant isolation" below. | +| `api` (Go) | gRPC + REST gateway. `POST /query` compiles pipe-syntax or raw SQL to one IR, executed across ClickHouse/Tantivy (`/docs/query-language-design.md`). `internal/dashboards` is CRUD only — panel query execution happens client-side, reusing `/query`. `internal/authz` (Phase 4) enforces RBAC via a network call to `enterprise-auth`, never an import. | +| `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). | +| `enterprise` (Go, commercial license, Phase 4) | SSO (OIDC/SAML protocol mechanics), RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), and `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`). Never imported by core — see "Licensing boundary" below. Does **not** yet include per-tenant ClickHouse/Tantivy connection routing or the OIDC/SAML login HTTP handlers — see `/docs/security/threat-model.md`. | +| `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling enterprise-licensed components. | +| `cli` (`sentryctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$SENTRYCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). | +| `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. | + +## Tenant isolation model (Phase 4) + +Full design rationale: `/docs/phase-4-isolation-design.md`. Full honest +accounting of what's actually enforced vs. designed-only: +`/docs/security/threat-model.md` — read that before assuming any claim +below holds for log data specifically. + +**As designed:** one dedicated ClickHouse database + narrowly-granted +user per tenant (never the shared `default`/admin credential), one +dedicated Tantivy index directory per tenant, `system.*` access revoked +per tenant, connections resolved from an immutable per-tenant map (never +a shared pool with session-level `USE`). Isolation lives at the +**connection layer** — every query, compiled or raw SQL, is forced +through a tenant-scoped connection the database's own access control +enforces — not at the query-compiler layer, since Phase 2's raw-SQL +escape hatch is opaque to any compiler-injected filter. + +**As built, through Phase 4 task 8:** + +- Role-based access control (`api/internal/authz`) is live on `/query` + and `/dashboards`, resolved via `enterprise-auth` over HTTP. +- Control-plane tenant scoping is live for dashboards + (`api/internal/dashboards`'s store filters every query by the + authenticated identity's tenant, never a client-supplied field). +- The `alerting`↔`api` service-identity gap (task 2's finding) is + closed: a `RoleService` credential, distinct from every human role. +- **The connection-layer isolation itself — the actual design above — + is not built.** `api/internal/querylang/executor.SQLRunner`/ + `SearchClient` and `search`'s gRPC service carry no tenant field + anywhere. There is one shared ClickHouse connection and one shared + Tantivy index for every tenant. RBAC controls *who* can run a query; + nothing yet controls *what data* that query can see. +- `deploy/operator`'s `Tenant` CRD manages only the K8s-side artifact (a + credential Secret) — it doesn't call ClickHouse or provision anything + ClickHouse-side. `enterprise/internal/tenantprovision` (the piece that + would) is unbuilt. + +Building `enterprise/internal/chrunner` + `internal/searchclient` (the +tenant-scoped implementations of the two interfaces above) and wiring +them into `api/internal/queryapi.Handler` in place of the single shared +connection `api/cmd/api/main.go` opens today is the single largest +remaining gap between this system and the isolation model it was +designed to have. ## Licensing boundary -AGPLv3 for core + agents. Enterprise features (SSO, multi-tenancy, -compliance) live under `enterprise/` (not yet created — out of scope for -Phase 0) under a commercial license stub. AGPL code must never import from -`enterprise/`. No enterprise-gated code exists yet in this repo; this -section documents the boundary so nothing added later crosses it by -accident. +AGPLv3 for core + agents. Enterprise features (SSO, RBAC storage, audit +logging) live under `enterprise/` (commercial license stub, added +Phase 4). AGPL code must never import from `enterprise/` — enforced in +CI by `hack/check-tenant-boundary.sh`, which greps every build for the +import edge. Where core needs a decision only `enterprise/` can make +(is this request authorized, what SSO is configured), it calls +`enterprise-auth` over plain HTTP instead +(`api/internal/authz.HTTPAuthorizer`, `web`'s `GET /auth/features`) — +the same "network boundary, not import boundary" shape `/alerting`↔`api` +already used before `enterprise/` existed. ## Non-negotiables carried from CLAUDE.md - Rust agent: statically linked musl, `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`, no glibc runtime deps. -- Windows support (Phase 1+) via native ETW/Event Log API, not WSL. +- Windows support via native ETW/Event Log API, not WSL — designed + (Phase 1) but still unverified on real Windows hardware. - Every UI action maps to a documented REST/gRPC call — no UI-only logic. - Pinned stack (see CLAUDE.md table) — no substitutions without discussion. -## Explicitly out of scope for Phase 0 +## Explicitly out of scope (current, Phase 4) -Windows agent, alerting, dashboards, multi-tenancy, Tantivy full-text -search, the real SPL-like query language, enterprise module code. +Per `/CLAUDE.md`'s Phase 4 non-goals and `/docs/security/threat-model.md`: +deny-override permission grants, a data retention/deletion policy for +deprovisioned tenants, general multi-cluster orchestration in `/deploy`, +and any defense against a privileged ClickHouse/Postgres administrator — +every isolation and audit-integrity guarantee here is a structural +defense against application-layer bugs, not an operational control. ## Open questions for you to resolve diff --git a/docs/phase-4-isolation-design.md b/docs/phase-4-isolation-design.md new file mode 100644 index 0000000..38267a4 --- /dev/null +++ b/docs/phase-4-isolation-design.md @@ -0,0 +1,317 @@ +# Tenant isolation design + +> **Status:** Design, awaiting sign-off. Task 2 of Phase 4 — the highest- +> risk decision in the project so far, per explicit instruction: stop +> here before any code is written. This document was pressure-tested by +> an adversarial design review before being written up (not just +> reasoned through once and accepted) — several of the "required design +> elements" below exist specifically because that review found concrete +> bypass scenarios in an earlier draft, not because they're generically +> prudent. If implementation reveals this design is wrong somewhere, fix +> this doc in the same change — same discipline as every prior phase's +> design docs. + +## Why this design, in one paragraph + +Phases 0–3 are entirely single-tenant with zero authentication anywhere. +Phase 4 needs to isolate tenant data with a security-review-credible +guarantee, and the central fact shaping everything below is that Phase +2's query language has a raw-SQL escape hatch that is deliberately +*opaque* — never parsed, never validated against a schema +(`/docs/query-language-design.md`). That single fact rules out row-level +filtering (a `tenant_id` column plus a compiler-injected `WHERE` clause) +as the *sole* isolation mechanism: a filter the compiler injects +categorically cannot apply to a query the compiler never parses. So the +real isolation boundary has to live one layer down, at the database +connection itself — a tenant's ClickHouse user simply has no grant to +read another tenant's database, and no application code, compiled query +or hand-written SQL, can change that. Everything else in this document +is either implementing that connection-layer boundary correctly or +closing a gap the adversarial review found in a naive version of it. + +## Module placement: `enterprise/` only, confirmed with the project owner + +The tenant-isolation mechanism described here — per-tenant ClickHouse +database/user, per-tenant Tantivy index, the `TenantID` plumbing — +ships entirely in `enterprise/`, not AGPL core. Core (`/api`, +`/alerting`, `/web`) stays genuinely single-tenant: no multi-tenant +mechanism present at all, not merely a missing management UI on top of +otherwise-functional isolation. This was an explicit choice put to the +project owner rather than assumed, because CLAUDE.md's licensing +boundary text names multi-tenancy as enterprise-gated, and a +"mechanism in core, feature in enterprise" split would have let a +sufficiently motivated self-hosting AGPL user wire up real isolation +without ever touching `enterprise/` — undermining that boundary in +substance even while technically respecting the AGPL/commercial import +graph. Confirmed: enterprise-only. + +Mechanically, this works because `api/internal/querylang/executor` +already defines the seam Phase 2 needs regardless of tenancy: + +```go +type SQLRunner interface { + RunSQL(ctx context.Context, sql string) (*Result, error) +} +type SearchClient interface { + Search(ctx context.Context, query string, limit uint32) ([]string, error) +} +``` + +`enterprise/` supplies tenant-scoped implementations of these same +core-defined interfaces — Go interfaces don't require an import edge +from core to enterprise, only enterprise importing core's *interface +types*, the allowed direction. Core's `querylang`/`executor` packages +need zero changes for tenancy; `ChRunner` (today's single-connection +implementation, `api/internal/querylang/executor/chrunner.go`) stays +exactly as it is for single-tenant deployments, and `enterprise/` +provides an alternate implementation for multi-tenant ones. + +## A framing correction, stated plainly rather than built around + +The original task language asks for "compile-time... structurally +impossible to bypass" enforcement in the query compiler. Given the +raw-SQL passthrough above, that specific phrasing isn't achievable in +any module — there's no parse step to inject a filter into. The +achievable, honest version: **every code path, compiled query or raw +SQL, is forced through a tenant-scoped connection that the database's +own access-control system enforces.** The structural guarantee is at +the connection/index layer, not the compiler layer. This document's +"required design elements" are what make that connection-layer +guarantee actually hold under concurrency, partial failure, and +ClickHouse's own default-permissive corners — not decoration on top of +an already-sufficient row filter. + +## ClickHouse: database-per-tenant, grant-enforced + +One ClickHouse database + one dedicated, narrowly-granted ClickHouse +user per tenant, on the shared cluster by default. A tenant can later be +pinned to dedicated cluster nodes (Phase 4 task 6, a deployment-topology +decision) for large/regulated customers — that changes *where* a +tenant's database physically runs, not this model. `enterprise/` holds a +small map of per-tenant `*ChRunner`s — today's `chrunner.go` shape (one +`driver.Conn`, `Auth.Database` fixed at construction) is already exactly +right, this just needs N of them instead of one. **No `tenant_id` +column on `logs`**: isolation is a connection-level property, so there +is nothing else in a tenant's own database to filter or leak through a +missed `WHERE` clause. + +### Required design elements + +Each of these closes a specific bypass the adversarial review found in +a naive version of "just give each tenant a database": + +**1. No tenant traffic ever authenticates as ClickHouse's `default` +user.** Today's `docker-compose.yml` sets `CLICKHOUSE_PASSWORD` on the +implicit `default` user for the whole stack — a Phase 0–3-appropriate +shortcut that must not carry into tenant-scoped connections. `default` +(or an equivalent broad-access account) is reserved for +migrations/provisioning/ops only, never handed to a request-serving +code path. + +**2. `system.*` access is explicitly revoked from every tenant user, +not left at whatever ClickHouse's default template grants.** +`system.query_log` records every query's full text by default, and is +broadly readable unless explicitly revoked — so even with per-database +row isolation working *perfectly*, a tenant able to read +`system.query_log` can see other tenants' query text: predicate values, +field names, sometimes literally sensitive data embedded in a `WHERE` +clause. `SHOW DATABASES`/`system.tables` visibility being properly +grant-scoped is version-dependent on `access_management` actually being +engaged for the account, not the default `users.xml`-style setup. This +is not assumed from documentation — it's verified by an adversarial +integration test (Phase 4 task 8) that, as a tenant-scoped user, +attempts `SELECT * FROM system.query_log`, `SELECT * FROM +system.tables`, `SHOW DATABASES`, and a fully-qualified cross-tenant +`SELECT * FROM .logs`, asserting each is denied or +empty. Provisioning (`enterprise/internal/tenantprovision`) explicitly +revokes/never-grants `system.*` as part of creating a tenant user. + +**3. Per-tenant connections are fully separate `driver.Conn`/pool +objects — never one shared pool with session-level `USE tenant_x`.** A +shared-pool-plus-`USE` implementation is a real concurrency bug, not a +theoretical one: a connection recycled between tenants mid-flight can +interleave a `USE` statement for tenant A with a query that actually +executes against tenant B's still-live session state, depending on how +`clickhouse-go/v2` recycles connections under load. This design +mandates N fully separate pools, resolved fresh per request — as a +local variable inside the request-handling goroutine, never cached in a +mutable struct field shared across goroutines — from an +immutable-after-startup `map[TenantID]*ChRunner`. Growing or shrinking +that map (tenant on/offboarding) happens by replacing the map wholesale +(copy-on-write), never by mutating it in place under concurrent readers. + +**4. Provisioning is ordered, idempotent, and gated on an explicit +`active` state.** Sequence: `CREATE USER IF NOT EXISTS` with a +zero-privilege base role (not ClickHouse's implicit default profile) → +`GRANT` narrow, tenant-database-scoped access → only *then* mark the +tenant `active` in the `tenants` table (Postgres, `/metadata`). Every +tenant-resolution code path refuses to serve a tenant not in `active` +state, checked against that table server-side — never inferred from "a +connection happened to succeed," which would happily serve traffic +during a half-finished provisioning run. A crashed/retried provisioning +job must not leave a *broader*-than-intended grant live during the +retry window; the ordering above (narrow grant strictly before +`active`) is what prevents that. Deprovisioning must not leave a live +cached connection usable past a revoked grant on some ClickHouse +versions dropping a user doesn't terminate already-open sessions — so +offboarding either explicitly terminates sessions for the tenant's user +or relies on a bounded max lifetime for cached per-tenant connections +(not indefinite reuse). + +## Tantivy: index-per-tenant + +Same underlying reasoning as ClickHouse, for a sharper reason: Tantivy +has no grant system at all, so "one shared index with a tenant-tagged +field, filtered at query time" would have *zero* structural backing — +purely conventional, exactly the "convention, not structural" failure +mode this whole design exists to avoid. `search`'s current shape (one +`Arc` opened once at startup — `search/src/index.rs`, +`search/src/main.rs`) becomes a registry: a `HashMap>` (an LRU if tenant count ever grows large enough that +holding every index open simultaneously is wasteful — not needed for +Phase 4's initial scale), each index a separate directory under a +shared volume, opened or created on demand and resolved only from the +tenant context `enterprise/`'s trusted caller establishes. +`search.proto`'s `SearchRequest` gains a tenant field, populated +exclusively by `enterprise/`'s tenant-scoped `SearchClient` +implementation — never read from anything a remote/external client +supplies. + +## The `alerting` ↔ `api` gap + +Found by the adversarial review, not present in the first draft — real, +not hypothetical, and it has to be resolved as part of this sign-off +because it shapes Phase 4 task 5's design directly. + +**Today's actual behavior**: `alerting`'s evaluator +(`alerting/internal/evaluator/evaluator.go`) claims due rules across +*all* tenants in a single `rulestore.ClaimDueRules` call, then for each +one calls `api`'s `POST /query` via `internal/queryclient/client.go` +with just `{query, language}` — no tenant field, no authentication, at +all, today. + +**Why this matters for isolation specifically**: `alerting` is a +machine calling on a schedule, not a human with a session — there is no +session to derive a tenant context from the way a browser request has +one. The tempting, wrong fix is adding a `tenant_id` field to the +`/query` request, populated from the rule's own `TenantID` (which +`rulestore.RuleWithState` already carries after Phase 4's schema +additions). That is *exactly* the client-suppliable tenant identifier +this entire design exists to prevent — `alerting`'s HTTP surface is +unauthenticated today, so anything able to reach it (or spoof a call to +`api` shaped like one) could request any tenant's data by setting that +field. + +**The correct fix**, scoped into Phase 4 task 5: a distinct **service +identity** for `alerting` — a signed service token or mTLS client +certificate, not a human session — that `api`/`enterprise/` map to "may +execute the query belonging to rule X," where rule X's tenant is looked +up **server-side** from `alert_rules.tenant_id` (already present after +this phase's schema work), never taken from anything in the request +body. This is a third RBAC category, alongside human roles (Phase 4 +task 3), not a variant of session/token handling — it authorizes "run +this one already-persisted, already-tenant-scoped rule," never general +tenant access, so a compromised evaluator can't be used to browse +arbitrary tenant data. + +## `TenantID`: an honest framing, not an oversold one + +```go +package tenant + +type contextKey struct{} // unexported key type -- closes a + // context.WithValue collision gap: an + // exported or string-typed key could be + // shadowed/overwritten by unrelated code +type ID struct{ value string } // unexported field + +func (id ID) String() string { return id.value } +func FromContext(ctx context.Context) (ID, bool) +func WithContext(ctx context.Context, id ID) context.Context + +// TrustFromValidatedSession is the only production construction path +// from a raw string. DO NOT CALL OUTSIDE auth middleware -- enforced by +// CI grep (hack/check-tenant-boundary.sh), same mechanism as the +// enterprise/-import-boundary check Phase 4 task 3 adds. +func TrustFromValidatedSession(raw string) ID +``` + +An unexported field with exactly one production constructor makes +*accidental* misuse cheap to audit — grep for call sites — it does not +make misuse impossible by the Go compiler alone, and this document +states that plainly rather than implying otherwise. Concretely: + +- It does not stop a *deliberate* second construction path added later + inside the `tenant` package itself — e.g. a future `UnmarshalJSON` + method, added for some unrelated serialization need, which has full + access to the unexported field from within the package and would + happily decode a client-supplied JSON body straight into a trusted + `ID` the moment any handler unmarshals into a struct embedding one. +- **Correction made during implementation**: this design originally + proposed a test-only constructor in `internal/tenant/testing_test.go`, + reasoning that Go's exclusion of `_test.go` files from normal imports + would make it a compiler-enforced constructor reachable by other + packages' tests but not production code. That reasoning was wrong — + Go never compiles `_test.go` files into what *any* other package + imports, including other packages' own tests, so that constructor + would have been unreachable even from its intended callers. There is + no separate test constructor: other packages' tests call + `TrustFromValidatedSession` directly, which is fine, since test code + isn't attacker-controlled the way a network-facing handler is. +- The actual invariant, stated for what it is: *the constructor has + exactly one call site in non-test production code, verified by CI + grep (scanning `*.go`, excluding `*_test.go`) plus code review at + every change to `internal/tenant/`. The database/index grant layer + above is the real backstop. This package makes production violations + visible and rare — it does not make them impossible, and was never + able to restrict test-time construction either, only make it + unnecessary to restrict.* + +## Provisioning state machine (summary — full detail in task 6's deploy work) + +``` +provisioning → active → suspended → deprovisioning → (removed) +``` + +- `provisioning`: `tenants` row exists, ClickHouse user/grants and + Tantivy index directory are being created. No request is ever served + for a tenant in this state. +- `active`: fully provisioned, narrow grants confirmed applied. Normal + serving state. +- `suspended`: grants revoked (e.g. non-payment, policy violation) but + data retained; no requests served, distinct from `deprovisioning` so + a suspension can be reversed without re-provisioning from scratch. +- `deprovisioning`: offboarding in progress — sessions/connections being + terminated, data export/deletion per retention policy (out of scope + for this document; a compliance/data-retention design, not an + isolation one). + +## What this document deliberately does not solve here + +- The RBAC role model and its enforcement (Phase 4 task 3, separate + sign-off). +- Audit logging mechanics (Phase 4 task 4). +- Exact SSO protocol flows (Phase 4 task 3). +- Deployment topology for pinning large tenants to dedicated cluster + nodes (Phase 4 task 6) — this document's model is agnostic to where a + tenant's database physically runs, only that it's a distinct + database/user regardless of placement. +- Data retention/deletion semantics during deprovisioning. + +## Verification plan for this design specifically + +Not just unit tests — this design's real risk is in ClickHouse's actual +runtime grant behavior on the pinned version in `docker-compose.yml`, +which is exactly the kind of thing that looks fine in documentation and +isn't in practice (see item 2 above). Phase 4 task 8's adversarial +suite must include, against the live stack: + +- A tenant-scoped ClickHouse user attempting to read another tenant's + database by fully-qualified name in raw SQL. +- The same user attempting `system.query_log`, `system.tables`, `SHOW + DATABASES`. +- A Tantivy search request for one tenant returning zero cross-tenant + results even when another tenant's index contains matching terms. +- A simulated evaluator tick firing mid-provisioning (tenant row exists, + grants not yet confirmed) to confirm it's refused, not silently served + against a partially-provisioned or default-profile connection. diff --git a/docs/phase-4-rbac-design.md b/docs/phase-4-rbac-design.md new file mode 100644 index 0000000..55217f3 --- /dev/null +++ b/docs/phase-4-rbac-design.md @@ -0,0 +1,213 @@ +# RBAC design + +> **Status:** Design, awaiting sign-off. Task 3 of Phase 4 — stop here +> before implementing enforcement/store code (`internal/rbacstore`, +> the `internal/session` middleware, RBAC checks wired into `/api`, +> `/alerting`, `/web`), per explicit instruction, same discipline as +> `/docs/phase-4-isolation-design.md`. Read that document first — this +> one assumes its module-placement decision (isolation and RBAC +> mechanisms live in `enterprise/` only) and its `alerting`↔`api` +> service-identity finding, which this doc's role model has to account +> for as a distinct category, not a variant of human roles. + +## Why this design, in one paragraph + +A plain three-tier viewer/editor/admin model (the original ask) has two +real gaps once you actually walk through who does what: nothing prevents +admins from locking each other out of a tenant entirely (the last admin +demotes themselves, or two admins race to remove each other — a shape of +bug organizations with real access-control systems hit often enough that +GitHub/GitLab-style products all converge on the same fix), and nothing +answers "who can grant additional access to a specific resource" — if +any editor can, an editor can silently self-escalate. This design adds a +non-removable `Owner` above `Admin` for the first gap, and scopes +per-resource grant management to resource creators + admins/owner for +the second, with every grant change itself audit-logged (task 4) — the +exact thing a security reviewer asks "show me every time access +widened" about. It also formalizes `alerting`'s evaluator as a distinct +**service identity** category, not a point on the human role scale, +because task 2's design doc found it needs categorically narrower +authority ("run this one already-persisted rule's query," never general +tenant browsing) than even a Viewer has. + +## Roles + +**Owner** → **Admin** → **Editor** → **Viewer**, strictly ordered — each +role's baseline access is a superset of the one below it. Exactly one +Owner per tenant at a time; Owner is non-removable and non-demotable +except by itself (voluntary transfer) or a platform operator +(break-glass, itself audit-logged and out of normal tenant-admin +control). This is the answer to "can admins lock each other out": they +can't lock out the Owner, and the Owner can always recover. + +Plus **additive-only per-resource grants** — a `dashboard_permissions` +row lets a specific user exceed their baseline tenant role on one +specific dashboard (e.g. a Viewer given Editor-level access to one +dashboard they need to maintain, without making them a tenant-wide +Editor). No deny-overrides: a grant can never take away access someone's +baseline role already has. Deny-overrides (restricting a specific +Editor from a specific sensitive dashboard, say) are named future work, +not solved here — the additive-only model is simpler to reason about +and implement correctly, and covers the more common real need ("let this +one person help with this one thing"). + +Plus a distinct **service identity** category, not on the role scale at +all: `alerting`'s evaluator authenticates as itself (a service +credential, task 5), authorized narrowly to "execute the query belonging +to already-persisted rule X, where X's tenant is resolved server-side" — +never general read access to a tenant's dashboards, users, or anything +else. A compromised or buggy evaluator can re-run known rule queries; it +cannot browse. + +## Permission matrix + +| Action | Viewer | Editor | Admin | Owner | +|---|---|---|---|---| +| View dashboard (baseline or granted access) | ✓ | ✓ | ✓ | ✓ | +| Create dashboard | ✗ | ✓ | ✓ | ✓ | +| Edit/delete dashboard | ✗ | ✓ (own, or granted) | ✓ (any) | ✓ | +| Manage a dashboard's per-user grants | ✗ | ✓ (only if creator) | ✓ | ✓ | +| Run ad hoc query (UI/CLI/API) | ✓ | ✓ | ✓ | ✓ | +| Create/edit/delete alert rule | ✗ | ✓ | ✓ | ✓ | +| Enable/disable alert rule | ✗ | ✓ | ✓ | ✓ | +| View alert delivery log | ✓ | ✓ | ✓ | ✓ | +| Create/edit notification target | ✗ | ✓ | ✓ | ✓ | +| View notification target secret (webhook URL/token) | ✗ | ✗ | ✓ | ✓ | +| Delete notification target | ✗ | ✗ | ✓ | ✓ | +| View data source config | ✓ | ✓ | ✓ | ✓ | +| Manage tenant users / role assignments | ✗ | ✗ | ✓ (not Owner) | ✓ | +| Manage SSO config | ✗ | ✗ | ✓ | ✓ | +| View tenant audit log | own history only | own history only | ✓ | ✓ | +| Transfer tenant Owner | ✗ | ✗ | ✗ | ✓ (or platform break-glass) | + +Every row in the "Admin/Owner-only" section that changes *someone else's* +access (role assignment, grant management, SSO config, Owner transfer) is +written to the audit log (task 4) as its own event type, distinct from a +query execution — reviewers ask for this list specifically. + +Editors intentionally cannot see notification target secrets (webhook +URLs/tokens often encode credentials) even though they can select and +use a target when creating a rule — this mirrors the existing plaintext- +secret disclosure in `/docs/phase-3-alerting-design.md`'s "Known gaps": +Phase 3 already flagged `notification_targets.secret` as stored +plaintext; RBAC narrows *who can read it back*, it doesn't change how +it's stored (that's still named future hardening work, not solved here). + +## `data_sources`: the honest scope of "per-data-source scoping" + +Today, and through the end of this phase, every tenant has exactly one +data source: their own ClickHouse database + Tantivy index pair from +`/docs/phase-4-isolation-design.md`. A `data_sources` table (tenant- +scoped, one row auto-created per tenant at provisioning time) exists as +the extension point for a real future multi-source-per-tenant feature — +e.g. a tenant connecting a second ClickHouse cluster, or a distinct log +stream with its own retention. Role grants can reference a +`data_source_id` in the schema now, but with one data source per tenant +there is nothing meaningfully different a per-data-source grant does +yet. Stated plainly so this doesn't read as more built than it is. + +## Schema + +Lives in `/metadata` (`sentry_metadata`), alongside everything else from +Phase 3, per `/docs/phase-4-isolation-design.md`'s existing schema +additions (`tenants`, the `tenant_id` backfill on `alert_state`/ +`delivery_log`). New tables, continuing that migration sequence: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + -- Identity provenance, not a password -- SSO is the only login path. + -- One user row can in principle federate from either an OIDC or a + -- SAML IdP; which one isn't fixed at the user level, it's determined + -- per tenant_memberships row via the tenant's configured SSO method. + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tenant_memberships ( + tenant_id TEXT NOT NULL REFERENCES tenants(id), + user_id UUID NOT NULL REFERENCES users(id), + role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, user_id) +); +-- Application-level invariant (not a DB constraint -- Postgres has no +-- native "exactly one row matching a predicate per group" check): +-- exactly one 'owner' row per tenant_id at a time. Enforced in +-- internal/rbacstore's transfer/provisioning logic, not the schema. + +CREATE TABLE data_sources ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + name TEXT NOT NULL DEFAULT 'default', + clickhouse_database_name TEXT NOT NULL, + tantivy_index_path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE dashboard_permissions ( + id UUID PRIMARY KEY, + dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id), + -- Additive only: this grant can only raise access above the user's + -- tenant-wide baseline role for this one dashboard, never lower it. + permission TEXT NOT NULL CHECK (permission IN ('viewer', 'editor')), + granted_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (dashboard_id, user_id) +); +``` + +`alert_rules`/`notification_targets`/`dashboards` already carry +`tenant_id` (Phase 3); no per-row ownership beyond `created_by` (already +present) is needed for the matrix above — "own vs. any" in the matrix is +`created_by = current user` vs. tenant-wide Admin/Owner authority, not a +separate grants table for those resource types. + +## Enforcement shape (design only — implementation is task 5) + +RBAC checks happen server-side, on every `/api`/`/alerting` endpoint +that touches tenant data — never UI-level button-hiding alone, per the +explicit requirement. The shape: middleware resolves `(tenant.ID, user, +role)` from a validated session (or, for `alerting`, the service +identity) before a handler runs; each handler declares the minimum role +an action requires; a request failing that check gets a 403 before any +tenant-scoped connection is even acquired — RBAC is a gate in front of +the isolation mechanism from `/docs/phase-4-isolation-design.md`, not a +replacement for it. Full middleware/handler wiring is task 5's scope. + +## Web UI boundary: a runtime capability check, not a conditional import + +Core `web` never bundles enterprise-licensed Svelte components into its +build — that would put commercial-licensed source inside an AGPL +artifact, the UI-layer equivalent of the Go import-boundary problem +`hack/check-tenant-boundary.sh` already guards against. Instead: core +`web` ships a generic settings/admin route +(`web/src/routes/settings/+page.svelte`, added in task 5) that, on load, +calls `GET {enterprise-auth base URL}/auth/features` and renders +sections conditionally based on the response: + +```json +{"sso_configured": true, "oidc_enabled": true, "saml_enabled": false} +``` + +If `enterprise-auth` isn't deployed or configured, that fetch fails or +returns all-`false`, and the settings page simply shows core-only +content — no broken links, no "upgrade to unlock" dead ends, just an +absent section. This is a runtime capability check against a documented +REST contract, the same pattern `web` already uses for its two backend +base URLs (`apiBase`/`alertingBase` in `web/src/lib/api.ts`), not a new +mechanism — just pointed at a third, optional backend. + +## What this document deliberately does not solve here + +- Deny-override grants (named future work above). +- Enforcement middleware implementation (task 5). +- Audit logging of grant/role changes (task 4 builds the audit log + itself; this doc only names which actions must be logged). +- SSO-to-role mapping policy (e.g. IdP group claims auto-assigning + roles) — a real feature, not designed here; Phase 4's baseline is + manual role assignment by an Admin/Owner after a user's first SSO + login creates their `users` row. diff --git a/docs/phase-4-runbook.md b/docs/phase-4-runbook.md new file mode 100644 index 0000000..b6da9da --- /dev/null +++ b/docs/phase-4-runbook.md @@ -0,0 +1,233 @@ +# Phase 4 runbook + +Extends `/docs/phase-0-runbook.md` through `/docs/phase-3-runbook.md` +with SSO plumbing, RBAC enforcement, tenant-scoped dashboards, audit +logging, and a Kubernetes deployment path. Read those first. + +## Verification status — read this before the rest of this doc + +Every prior phase's runbook documents claims **checked against the live +stack**, not asserted. This one is different, and says so plainly rather +than papering over it: **this session had no working Docker daemon +access and no reachable Kubernetes cluster**, so most of what follows is +a *procedure to run*, not a report of what was already run and passed. +Two exceptions, genuinely verified live against a real Postgres during +earlier Phase 4 tasks (see their own doc comments for the exact `docker +run` invocations): + +- `enterprise/internal/audit`'s hash-chain, tamper-detection, and + concurrent-write guarantees (task 4). +- `enterprise/internal/rbacstore`'s CRUD, run against a live Postgres + the same way. + +Everything else below — the auth-enforcement walkthrough, the dashboards +tenant-scoping fix, the Helm chart, the tenant-operator — has unit/fake- +client/`helm template` coverage (all passing, see each component's own +`go test`/`helm lint` output) but has **not** been exercised against a +real running stack in this session. If you're reading this to decide +whether Phase 4 is production-ready: it isn't yet, independent of this +gap — see `/docs/security/threat-model.md`'s headline finding (log-data +query isolation isn't built). This runbook exists so the first person +with real Docker/K8s access can actually close the loop, not to claim +that already happened. + +## 1. Bring up the stack + +```sh +docker compose build enterprise-auth api alerting web +docker compose up -d +docker compose ps +``` + +New service beyond Phase 3: `enterprise-auth` (port 8082) — see +`enterprise/README.md`. Not wired into `api`/`alerting`'s enforcement by +default (`docker-compose.yml`'s comment on why: no OIDC/SAML login flow +exists yet, so turning on enforcement by default would break the web UI +and `sentryctl` with no way to log in). + +## 2. Confirm Phase 0-3 behavior is unchanged + +Every existing single-tenant flow must still work exactly as before — +this is the regression check for the nil-authorizer no-op design running +through every piece of Phase 4 auth wiring: + +```sh +curl -s -X POST http://localhost:8080/query -H 'Content-Type: application/json' -d '{"query":"stats count"}' +sentryctl dashboards list +curl -s http://localhost:8081/healthz +``` + +All three should behave exactly as in the Phase 3 runbook — no auth +required, since `ENTERPRISE_AUTH_URL`/`API_SERVICE_TOKEN` aren't set. + +## 3. `enterprise-auth`: mint and validate a service token + +```sh +curl -s http://localhost:8082/healthz && echo " <- OK" + +TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=alerting) +echo "$TOKEN" + +curl -s -X POST http://localhost:8082/internal/authorize -H "Authorization: Bearer $TOKEN" +# expect: {"tenant_id":"","user_id":"","role":"service"} + +curl -s -o /dev/null -w "invalid token -> %{http_code}\n" \ + -X POST http://localhost:8082/internal/authorize -H "Authorization: Bearer garbage" +# expect: 401 + +curl -s http://localhost:8082/auth/features +# expect: {"sso_configured":false,"oidc_enabled":false,"saml_enabled":false} +# (no OIDC_ISSUER_URL/SAML_IDP_METADATA_URL set in this compose file) +``` + +## 4. Turn on RBAC enforcement and prove it actually blocks/allows + +Without touching the main stack's `api` container (so step 2's baseline +keeps working): + +```sh +docker compose run --rm -d --name sentry-api-enforced -p 8090:8080 \ + -e ENTERPRISE_AUTH_URL=http://enterprise-auth:8082 api + +curl -s -o /dev/null -w "no auth -> %{http_code} (want 401)\n" \ + -X POST http://localhost:8090/query -H 'Content-Type: application/json' -d '{"query":"stats count"}' +curl -s -o /dev/null -w "with service token -> %{http_code} (want 200)\n" \ + -X POST http://localhost:8090/query -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"query":"stats count"}' + +docker stop sentry-api-enforced +``` + +`GET /dashboards` on the same enforced instance should return 401 +without a token — there's no way to mint a human (Viewer/Editor/etc.) +session yet (no OIDC/SAML login handler exists — see +`enterprise/cmd/enterprise-auth/main.go`'s doc comment), so this +runbook can't walk through a real human RBAC scenario end to end. That +gap is real, not an oversight in this runbook. + +## 5. Dashboards tenant scoping + +This is the fix from Phase 4 task 7/8 (see `/docs/security/threat-model.md`) +— every dashboards query is now scoped to the authenticated identity's +tenant. Verify the real SQL, not just the fake-store unit tests: + +```sh +docker run --rm --network sentry_default -v $(pwd)/api:/src -w /src \ + -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ + -e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \ + golang:1.25-alpine go test ./internal/dashboards/... -run Integration -v +``` + +Expect all `TestIntegration*` tests to pass, including +`TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant` (the +`tenant_id` foreign key added in +`metadata/migrations/0027_add_dashboards_tenant_fk.sql` rejecting a +dashboard for a tenant that doesn't exist). + +## 6. `enterprise/internal/rbacstore` and `internal/audit` (already verified — reconfirm here) + +```sh +docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \ + -e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ + -e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \ + golang:1.25-alpine go test ./internal/rbacstore/... -v + +docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \ + -e AUDIT_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ + -e AUDIT_TEST_POSTGRES_PASSWORD=audit-writer-dev-only \ + -e AUDIT_TEST_ADMIN_PASSWORD=sentry-dev-only \ + golang:1.25-alpine go test ./internal/audit/... -v +``` + +## 7. `deploy`: Helm chart and Operator (offline-only so far — see `/deploy/README.md`) + +No live cluster was available to `kubectl apply` any of this. What can +be checked without one: + +```sh +cd deploy/operator && go build ./... && go vet ./... && go test ./... + +cd ../helm/sentry +helm lint . +helm template sentry . --include-crds > /tmp/default.yaml +helm template sentry . --include-crds \ + --set enterprise.enabled=true --set tenantOperator.enabled=true \ + --set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' \ + --set 'tenants[1].name=globex' --set 'tenants[1].displayName=Globex Corporation' \ + > /tmp/multitenant.yaml +``` + +With a real cluster reachable (`kind create cluster`, or similar): + +```sh +docker build -f deploy/operator/Dockerfile -t sentry-tenant-operator deploy/operator/ +kind load docker-image sentry-tenant-operator # or push to a registry the cluster can pull from +helm install sentry deploy/helm/sentry --include-crds \ + --set tenantOperator.enabled=true --set enterprise.enabled=true \ + --set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' +kubectl get tenants +kubectl get secret sentry-tenant-acme-clickhouse -o yaml +``` + +Expect `kubectl get tenants` to show `acme` reach `status.phase: Active` +and the Secret to contain a generated `username`/`password`/`database`. +This proves the K8s-side half of a real two-tenant deployment — it does +**not** prove either tenant has a working ClickHouse database, since +`enterprise/internal/tenantprovision` (the piece that would create one) +isn't built. See `/deploy/README.md` and +`/docs/security/threat-model.md`. + +## Known gaps (do not treat this phase as done without reading these) + +Full accounting: `/docs/security/threat-model.md`. Headline items: + +- **No tenant isolation on log data.** `POST /query` executes against + one shared ClickHouse connection and one shared Tantivy index for + every tenant, regardless of RBAC. This is Phase 4's originally-stated + highest-risk item and it is not resolved. +- **No human SSO login.** OIDC/SAML protocol wiring exists; + the HTTP login/callback handlers that would use it don't. +- **No per-resource dashboard grants** (`dashboard_permissions` has a + schema, no handler reads it). +- Four adversarial ClickHouse/Tantivy probes named in + `/docs/phase-4-isolation-design.md`'s verification plan are stubbed as + explicitly-skipped tests in `api/internal/queryapi/ + tenant_isolation_gap_test.go`, blocked on the tenant-scoped connection + work above. + +## Tearing down + +```sh +docker compose down -v +helm uninstall sentry # if installed against a real cluster +``` + +## Troubleshooting + +**`enterprise-auth` fails to start with "ENTERPRISE_SESSION_SIGNING_KEY +must be set to at least 32 bytes".** +Required, unlike OIDC/SAML config — see `enterprise/internal/config.Load`. +`docker-compose.yml`'s dev value is long enough; a custom override must +be too. + +**`POST /query` returns 401 even though `ENTERPRISE_AUTH_URL` isn't +set.** +Check `api/cmd/api/main.go` actually left `authorizer` nil when +`cfg.EnterpriseAuthURL == ""` — a nil `Authorizer` must be a no-op +(`api/internal/authz.RequireRole`'s doc comment). If this regresses, it +breaks every existing Phase 0-3 deployment silently. + +**A dashboard created by one tenant is visible to another.** +This is the exact bug found and fixed in task 7 — see +`/docs/security/threat-model.md`'s "application-layer tenant scoping" +section and `api/internal/dashboards/handler_test.go`'s +`TestCrossTenant*` tests. If this regresses, `Handler.tenantID` or +`store.go`'s `WHERE tenant_id = ...` filters have been bypassed +somewhere — check every store method still takes and uses a `tenantID` +parameter. + +**`helm template` fails with `error calling include: ... can't evaluate +field Release in type string`.** +A call site is passing a bare string to `sentry.selectorLabels` instead +of `(list $ "name")` — see `templates/_helpers.tpl`'s doc comment for +why the plain-string form doesn't work with `include`. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 0000000..95987e5 --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,289 @@ +# Sentry Threat Model (Phase 4) + +Written for a prospective enterprise customer's security team, describing +the system **as actually built** through Phase 4 task 7 — not the target +architecture. Where a control is designed but not yet implemented, this +document says so explicitly, with a pointer to the tracking doc/task. +See `/docs/phase-4-isolation-design.md` and `/docs/phase-4-rbac-design.md` +for the full design rationale behind the controls described here. + +## Read this first: the single most important open finding + +**Log data queried through `POST /query` is not tenant-isolated today.** +Every authenticated tenant's ad hoc queries and dashboard panel queries +execute against the same shared ClickHouse connection and the same +shared Tantivy index — there is no per-tenant database, user, or index +routing anywhere in the query execution path +(`api/internal/querylang/executor.SQLRunner`/`SearchClient`, `search`'s +gRPC service, `proto/sentry/search/v1/search.proto`). Confirmed by +reading the actual code, not assumed: neither interface, nor the +`search` proto, carries a tenant field anywhere. + +This is exactly the mechanism `/docs/phase-4-isolation-design.md` +specifies as the core deliverable of tenant isolation (one dedicated +ClickHouse database/user and one dedicated Tantivy index directory per +tenant) — it is **designed but not built**. What *is* built and live: +role-based access control (below) and tenant-scoped control-plane data +(dashboards, below). Until `enterprise/internal/chrunner` and +`enterprise/internal/searchclient` exist and are wired into +`api/internal/queryapi.Handler` in place of the single shared connection +`api/cmd/api/main.go` opens today, **treat any deployment of this system +as single-tenant only**, regardless of how many `Tenant` CRs or +`tenant_memberships` rows exist. RBAC controls who can run a query; they +do not control what data that query can see. + +## System overview + +``` +Browser ──▶ web (SvelteKit, static) + │ + ▼ +Browser ──▶ api ──▶ ClickHouse (log data, SQL path) + │ └─▶ search (gRPC) ──▶ Tantivy (log data, full-text path) + └─▶ Postgres (control plane: dashboards, alert_rules, + tenants, users, tenant_memberships, audit_log) + +alerting ──▶ api (POST /query, RoleService credential) +alerting ──▶ Postgres (rulestore, notifystore) + +api/alerting ──▶ enterprise-auth (POST /internal/authorize, HTTP only — + no Go import edge, see "Module + boundary" below) + +sentryctl ──▶ api, alerting (Bearer token when SENTRYCTL_TOKEN is set) +``` + +Ingest path (agent → Redpanda → ingest → ClickHouse, and Redpanda → +search → Tantivy) carries no tenant concept at all yet either — every +ingested log record lands in the one shared `logs` table/index. Tenant +isolation for *ingest*, not just query, is out of scope for what's built +so far and is not separately designed in +`/docs/phase-4-isolation-design.md`; named here as a gap that design doc +doesn't yet cover, not just an implementation gap. + +## Module boundary (trust boundary #1) + +`enterprise/` (commercial license: SSO, RBAC storage, audit logging, +session issuance) is never imported by AGPL core (`/api`, `/alerting`, +`/web`, `/cli`) — enforced in CI by `hack/check-tenant-boundary.sh`, +which greps for the import edge on every build. Core calls +`enterprise-auth` over plain HTTP (`api/internal/authz.HTTPAuthorizer`), +forwarding only the `Cookie`/`Authorization` headers, never the full +request (`api/internal/authz/httpauthz_test.go` asserts this — an +unrelated header like `X-Forwarded-For` is never forwarded). This means +core's authorization decision is only as trustworthy as the network path +to `enterprise-auth` — see "Deployment/network assumptions" below. + +## Authentication + +**Not implemented for human users.** `enterprise/internal/oidc` and +`enterprise/internal/saml` wire `coreos/go-oidc`/`crewjam/saml` for the +protocol mechanics (discovery, AuthnRequest generation, token/assertion +validation), but no HTTP handler calls them — there is no +`/auth/oidc/login`, `/auth/oidc/callback`, or SAML ACS endpoint. A human +cannot log in today. `GET /auth/features` (`enterprise/internal/ +authhandler`) reports whether OIDC/SAML are *configured* (for `/web`'s +settings page to conditionally render), which is independent of whether +login actually works. + +**Implemented for the one machine caller.** `/alerting`'s evaluator is +the sole service-to-service caller (`POST /query`, to evaluate rule +conditions across tenants). It presents a long-lived, signed +(HS256/JWT) `RoleService` credential, minted offline via +`enterprise-auth -mint-service-token=alerting` (an operator action, not +a network-reachable endpoint) and configured via `API_SERVICE_TOKEN`. +`enterprise/internal/session.Manager` issues and validates this token; +`enterprise/internal/authhandler`'s `POST /internal/authorize` resolves +it. `RoleService` is a distinct, non-comparable lane on the `Role` type +(`api/internal/authz.Role.Satisfies`) — a service credential can never +satisfy a human-role check and vice versa, verified by exhaustive +table-driven tests (`api/internal/authz/authz_test.go`). + +**Session/token integrity.** Tokens are HS256-signed JWTs with a single +shared signing key (`ENTERPRISE_SESSION_SIGNING_KEY`, ≥32 bytes, +required at `enterprise-auth` startup). Compromise of this key lets an +attacker forge any identity, including `RoleService` — it is the single +highest-value secret in the enterprise deployment and should be treated +accordingly (a real KMS/secrets-manager-backed value, not the +`docker-compose.yml`/Helm chart's dev-only literal). Token validation +(`enterprise/internal/session.Manager.Validate`) collapses every failure +mode — bad signature, malformed token, expired — into one +`ErrInvalidToken`, deliberately not distinguishing "expired" from +"forged" so a caller can't be tempted to treat either as a softer case. + +## Authorization (RBAC) + +**Live and enforced.** `POST /query` and every `/dashboards` endpoint in +`api` require a minimum role, resolved per-request via +`api/internal/authz.RequireRole`/`RequireRoleOrService` calling +`enterprise-auth`. Roles: Viewer < Editor < Admin < Owner, plus the +separate `RoleService` lane above. `GET /dashboards` is Viewer+; +create/update/delete require Editor+ (`api/internal/dashboards/ +handler.go`). A nil `Authorizer` (no `ENTERPRISE_AUTH_URL` configured) +is a deliberate no-op, matching Phase 0-3's no-auth behavior — this is +correct default-open-for-single-tenant behavior, not an oversight, but +means an operator who forgets to set `ENTERPRISE_AUTH_URL` in a +multi-tenant deployment gets *no* enforcement at all, silently. Worth a +deployment-time check a real rollout should add (not built here). + +**Not yet enforced:** the RBAC matrix's `(own/granted)` qualifier for +Editor-level dashboard actions — `dashboard_permissions` (per-resource +grants beyond a user's tenant-baseline role) has a schema +(`metadata/migrations/0024_create_dashboard_permissions.sql`) but no +handler reads it yet. Every Editor in a tenant can act on every +dashboard in that tenant, not just their own/granted ones. + +**Application-layer tenant scoping (dashboards only).** Every +`dashboards` store query filters `WHERE tenant_id = $identity.TenantID` +(`api/internal/dashboards/store.go`), and the handler resolves that +tenant ID from the RBAC-authenticated identity's context +(`authz.IdentityFromContext`), **never** from a client-supplied request +field. This closes a real gap found during this document's own review: +`Dashboard.TenantID` is a JSON-tagged, client-settable field +(`api/internal/dashboards/types.go`), and the original handler/store +implementation trusted it directly on create/update and applied no +`tenant_id` filter at all on list/get/update/delete — meaning any +authenticated user could read, modify, or delete any other tenant's +dashboards simply by supplying (or guessing) their UUID, or spoof +`tenant_id` on create/import to write into a tenant they don't belong +to. Fixed as part of this task, with regression tests proving +cross-tenant access now returns 404 (not 403, which would itself leak +that the ID exists under a different tenant) — +`api/internal/dashboards/handler_test.go`'s +`TestCrossTenant*`/`TestCreateDashboardIgnoresClientSuppliedTenantID`/ +`TestImportIgnoresExportedTenantID`. **This same class of bug should be +assumed present anywhere else client-supplied identifiers cross a tenant +boundary until proven otherwise by an adversarial test** — see task 8's +adversarial test suite for what's been checked so far and what hasn't. + +**Query-path tenant scoping: none** — see the top of this document. +RBAC's role check on `POST /query` answers "is this identity allowed to +run *a* query," not "does this query's result set respect tenant +boundaries" — it can't, because the executor has no tenant concept to +enforce. + +## Audit logging + +**Live**, and independently verified against a real Postgres (not just +written) — `enterprise/internal/audit`'s integration tests. Two +independent defenses back "no update/delete path from the application +layer": + +1. A dedicated `audit_writer` Postgres role with only `INSERT`+`SELECT` + grants (`metadata/migrations/0012-0014`), via its **own** + `pgxpool.Pool` — never the shared `sentry` role/pool every other + store uses. +2. A `BEFORE UPDATE OR DELETE ... RAISE EXCEPTION` trigger + (`metadata/migrations/0015-0016`) that rejects the operation for + *any* role, including the table owner — confirmed live: even the + `sentry` role cannot `UPDATE` a row without first disabling the + trigger, a privileged operation distinct from ordinary application + access. + +**Tamper detection, not tamper prevention against a privileged +attacker.** Rows are hash-chained (`prev_hash`/`row_hash = +SHA256(prev_hash || canonical_fields)`, serialized under +`pg_advisory_xact_lock` so concurrent writers can't fork the chain — +verified with a 20-goroutine concurrency test against live Postgres). +The chain alone only proves internal self-consistency: a Postgres +superuser (or anyone who compromises that credential) can wipe +`audit_log` and regenerate a perfectly self-consistent new chain from +row 1. `enterprise/internal/audit.Checkpointer` periodically ships a +rolling hash to an external `CheckpointSink` for exactly this reason — +`FileSink` (the only implementation built so far) is explicitly +documented as a dev/testing stand-in, **not** a real external-anchoring +guarantee (it writes to a local file the same privileged attacker could +also reach). A real deployment needs a genuine `CheckpointSink` +(S3 with Object Lock, or equivalent, reachable by a credential the +database administrator doesn't also hold) before the "prove nothing was +altered after the fact" claim actually holds against a privileged +insider. + +**Fail-open by design for routine queries.** `queryapi.Handler.logAudit` +(`api/internal/queryapi/handler.go`) logs a write failure and otherwise +ignores it — an audit-log outage does not take down the query path. This +is a deliberate availability-over-completeness tradeoff: it means a +brief audit outage produces an under-logged (not over-blocked) window. +No privileged/administrative action (role change, SSO config change, +notification-target secret reveal) currently exists to enforce +fail-closed on, since none of those flows are built yet +(`enterprise/internal/rbacstore` has no HTTP handlers) — when they are, +they should fail closed per `/docs/phase-4-isolation-design.md`'s +original policy, and that policy is not yet exercised by any real code +path. + +**What's logged:** query text, language, row count, duration, +success/error — not result contents. `Source`/`EventType` fields exist +(`SourceAPI`/`SourceWeb`/`SourceCLI`/`SourceAlerting`, +`EventQuery`/`EventRoleChange`/`EventGrantChange`/ +`EventSSOConfigChange`/`EventSecretReveal`) but only `EventQuery` from +`SourceAPI` is actually wired to a call site +(`queryapi.Handler.logAudit`) — the others are typed placeholders for +work not yet built (there's no role-change/grant-change/SSO-config +handler to call them from). + +## Known residual risks (explicitly out of scope, not silently assumed away) + +Per `/CLAUDE.md`'s Phase 4 non-goals, restated here in threat-model +terms: + +- **A privileged ClickHouse/Postgres administrator is not defended + against.** Every isolation and audit-integrity guarantee in this + document is a structural defense against *application-layer* bugs and + injection — not against someone holding database superuser + credentials. That's an operational control (credential custody, + infrastructure access review), out of scope for this system's own + code. +- **`system.query_log` metadata leakage** (task 2's finding): once + per-tenant ClickHouse users exist, `system.query_log` and related + `system.*` tables can expose other tenants' query *text* (predicate + values, field names) even if row-level isolation between databases + works perfectly. The design calls for revoking `system.*` access from + every tenant user explicitly, not relying on ClickHouse's default + template — this can only be verified once per-tenant users actually + exist (they don't yet; see the top of this document), so it remains + an open verification item, not a closed one. +- **No deny-override grants** — `dashboard_permissions` is additive-only + by design; a full allow/deny ACL system is unbuilt, future work. +- **No data retention/deletion policy** for a deprovisioned tenant — + the `tenants.status` state machine includes `deprovisioning`, but what + actually happens to that tenant's ClickHouse/Tantivy/Postgres data is + an unanswered compliance question, not a designed-and-deferred one. +- **No general multi-cluster orchestration** — `/deploy`'s Helm + chart/Operator (`/deploy/README.md`) proves the K8s-side per-tenant + secret-management model, not a fully general multi-cluster system, and + was never applied to a live cluster in this environment (see that + README's verification section). + +## Deployment/network assumptions + +- `enterprise-auth`'s `/internal/authorize` and `/auth/features` + endpoints have no authentication of their own beyond the credentials + they're validating — they must be reachable only from inside the + cluster/trusted network (`api`/`alerting`/`web`), never exposed + publicly. Nothing in this codebase enforces that at the network layer; + it's a deployment responsibility (NetworkPolicy, or equivalent) not + yet codified in `/deploy/helm/sentry`. +- `ENTERPRISE_SESSION_SIGNING_KEY`, ClickHouse/Postgres passwords, and + (once minted) the `alerting` service token are all K8s `Secret` + objects in the Helm chart (`/deploy/helm/sentry/templates/ + secrets.yaml`) — standard K8s `Secret` semantics apply (base64, not + encrypted at rest without a cluster-level `EncryptionConfiguration`). + No secrets-manager integration (Vault, cloud KMS) exists; the chart + documents this as an operator decision, not something it enforces. + +## Summary: what's actually enforced today + +| Control | Status | +|---|---| +| Role-based access control on `/query`, `/dashboards` | **Enforced** | +| `alerting`↔`api` service-identity credential | **Enforced** | +| Tenant scoping on dashboards (control-plane data) | **Enforced** (fixed this task) | +| Tenant isolation on log data (`/query` → ClickHouse/Tantivy) | **Not implemented** | +| Human SSO login (OIDC/SAML) | **Not implemented** | +| Per-resource dashboard grants (`own/granted`) | **Not implemented** | +| Query audit logging (routine queries) | **Enforced**, fail-open | +| Audit log tamper detection (hash chain) | **Enforced**, verified live | +| Audit log tamper prevention (external anchoring) | **Design only** — `FileSink` is a dev stand-in | +| `system.*` ClickHouse metadata isolation | **Unverified** — depends on unbuilt per-tenant users | +| Protection against a privileged DB administrator | **Explicit non-goal** | diff --git a/enterprise/Dockerfile b/enterprise/Dockerfile new file mode 100644 index 0000000..bc64eae --- /dev/null +++ b/enterprise/Dockerfile @@ -0,0 +1,12 @@ +# Commercial-license module, built the same way as every other Go +# service here -- no /proto dependency, context is enterprise/ itself, +# same shape as cli/Dockerfile and alerting/Dockerfile. +# docker build -f enterprise/Dockerfile -t sentry-enterprise-auth enterprise/ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/enterprise-auth ./cmd/enterprise-auth + +FROM gcr.io/distroless/static-debian12 +COPY --from=builder /out/enterprise-auth /enterprise-auth +ENTRYPOINT ["/enterprise-auth"] diff --git a/enterprise/README.md b/enterprise/README.md new file mode 100644 index 0000000..adad98a --- /dev/null +++ b/enterprise/README.md @@ -0,0 +1,148 @@ +# enterprise + +**Commercial license, not AGPLv3** — see `/CLAUDE.md`'s licensing +boundary. SSO (OIDC/SAML), tenant provisioning, and RBAC. Nothing in +`/agent`, `/ingest`, `/storage`, `/api`, `/web` core, or `/cli` imports +from this module — confirmed by `hack/check-tenant-boundary.sh`, run in +CI. `enterprise/` supplies tenant-scoped implementations of core's +already-shipped `api/internal/querylang/executor.SQLRunner`/ +`SearchClient` interfaces rather than core growing tenant awareness — +see `/docs/phase-4-isolation-design.md` for why. + +## Status + +Tasks 3-5 (module skeleton, SSO library wiring, audit logging, and auth +wiring in `/api`/`/web`/`/cli`) are built and tested. What's live +end-to-end: + +- `internal/session` issues/validates signed (HS256/JWT) tokens for both + human sessions and `/alerting`'s `RoleService` credential. +- `internal/authhandler` serves `POST /internal/authorize` (the endpoint + `api/internal/authz.HTTPAuthorizer` calls) and `GET /auth/features` + (the runtime-capability check `/web`'s settings page reads). +- `api`'s `/query` and `/dashboards` endpoints enforce RBAC via + `authz.RequireRole`/`RequireRoleOrService`, nil-safe (no-op) when + `ENTERPRISE_AUTH_URL` isn't configured -- matches Phase 0-3 behavior. +- `/alerting`'s `queryclient` presents a `RoleService` Bearer token + (`API_SERVICE_TOKEN`) when configured -- see + `/docs/phase-4-isolation-design.md`'s `alerting`↔`api` gap. +- `sentryctl` presents `$SENTRYCTL_TOKEN` as a Bearer credential on every + request when set. +- `internal/rbacstore`: full CRUD over `users`/`tenants`/ + `tenant_memberships` (`metadata/migrations/0017-0023`), verified + against a live Postgres. + +**Deliberately deferred, not half-built** -- named explicitly rather than +silently left out: +- The actual OIDC/SAML login/callback HTTP handlers that would issue a + *human* session after a real IdP round trip (`internal/oidc`/ + `internal/saml` do the protocol mechanics; nothing calls them from an + HTTP handler yet). `-mint-service-token` is the only way to get a + token today, and it only mints `RoleService` credentials. +- `dashboard_permissions`/`data_sources` CRUD (schema exists, + `metadata/migrations/0024-0026`; no caller reads per-resource grants + yet -- `dashboards`' handler enforces tenant-baseline role only, not + the matrix's "(own/granted)" qualifier). +- `internal/tenantprovision` (ClickHouse DB/user/grant + Tantivy index + provisioning) and the tenant-scoped `internal/chrunner`/ + `internal/searchclient` `SQLRunner`/`SearchClient` implementations -- + task 2's isolation model, not yet built against real per-tenant + connections. +- Wiring `internal/audit` into `api`'s `queryapi.AuditLogger` extension + point (built in core since task 4, still passed as `nil`). + +## Package layout + +``` +cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features endpoints, -mint-service-token +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/saml/ crewjam/saml wiring: SP setup, login redirect, response parsing/validation +internal/session/ issues/validates signed session + RoleService tokens +internal/authhandler/ POST /internal/authorize, GET /auth/features +internal/rbacstore/ users/tenants/tenant_memberships CRUD (pgx against sentry_metadata) +internal/audit/ append-only, hash-chained query audit log -- see its own package + doc comment and /docs/phase-4-isolation-design.md's audit section +internal/config/ env-var config, same convention as every other Go service here +``` + +Future additions: `internal/tenantprovision`, `internal/chrunner`/ +`internal/searchclient` (tenant-scoped `SQLRunner`/`SearchClient` +implementations), the OIDC/SAML login/callback HTTP handlers, and +`dashboard_permissions`/`data_sources` CRUD -- see "Status" above. + +## Why OIDC and SAML aren't hand-rolled + +`coreos/go-oidc` (built on `golang.org/x/oauth2`) and `crewjam/saml` +handle token/assertion signature verification, XML signing, and the +protocol-level trust establishment — exactly the parts of an SSO +integration where a from-scratch implementation is the highest-risk +code in the whole feature. Both are well-established libraries, matching +this project's existing "boring, well-understood dependency" pattern +(`clickhouse-go/v2`, `jackc/pgx/v5`). + +## Building & testing + +```sh +go build ./... +go vet ./... +go test ./... +``` + +`internal/audit`'s real guarantees (the `audit_writer` grant +restriction, the immutability trigger, hash-chain correctness under +concurrency) can only be proven against a real Postgres — those +integration tests are skipped by default and only run with +`AUDIT_TEST_POSTGRES_ADDR` set: + +```sh +docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \ + -e AUDIT_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ + -e AUDIT_TEST_POSTGRES_PASSWORD=audit-writer-dev-only \ + -e AUDIT_TEST_ADMIN_PASSWORD=sentry-dev-only \ + golang:1.25-alpine go test ./internal/audit/... -v +``` + +`internal/rbacstore`'s tests are the same shape (real SQL, real +constraints), skipped unless `RBACSTORE_TEST_POSTGRES_ADDR` is set: + +```sh +docker run --rm --network sentry_default -v $(pwd)/..:/src -w /src/enterprise \ + -e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ + -e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \ + golang:1.25-alpine go test ./internal/rbacstore/... -v +``` + +## Turning on auth enforcement for manual testing + +Off by default (see "Status" above -- there's no login flow to issue a +human session yet). To exercise the `RoleService` path end to end: + +```sh +docker compose up -d enterprise-auth +TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=alerting) +# api: set ENTERPRISE_AUTH_URL=http://enterprise-auth:8082 and restart +# alerting: set API_SERVICE_TOKEN=$TOKEN and restart +``` + +```sh +docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise/, not the repo root +``` + +## Environment variables + +| Var | Default | +|---|---| +| `HTTP_LISTEN_ADDR` | `:8082` | +| `POSTGRES_ADDR` | `localhost:5432` | +| `POSTGRES_DATABASE` | `sentry_metadata` | +| `POSTGRES_USERNAME` | `sentry` | +| `POSTGRES_PASSWORD` | (empty) | +| `OIDC_ISSUER_URL` | (empty — OIDC discovery skipped if unset) | +| `OIDC_CLIENT_ID` | (empty) | +| `OIDC_CLIENT_SECRET` | (empty) | +| `OIDC_REDIRECT_URL` | (empty) | +| `SAML_ENTITY_ID` | (empty) | +| `SAML_ACS_URL` | (empty) | +| `SAML_IDP_METADATA_URL` | (empty — presence only feeds `GET /auth/features`; not yet fetched/parsed) | +| `ENTERPRISE_SESSION_SIGNING_KEY` | **required**, min 32 bytes | diff --git a/enterprise/cmd/enterprise-auth/main.go b/enterprise/cmd/enterprise-auth/main.go new file mode 100644 index 0000000..3dcf17d --- /dev/null +++ b/enterprise/cmd/enterprise-auth/main.go @@ -0,0 +1,146 @@ +// Command enterprise-auth is Sentry's SSO/tenant-provisioning/RBAC +// service (commercial license, not AGPL) -- see +// /docs/phase-4-isolation-design.md and /docs/phase-4-rbac-design.md. +// +// Phase 4 task 5 adds session issuance/validation (internal/session) and +// the POST /internal/authorize endpoint api/internal/authz.HTTPAuthorizer +// calls -- the piece that actually turns on RBAC enforcement in /api. +// Still deliberately missing: the OIDC/SAML login/callback HTTP handlers +// that would issue a *human* session after a real IdP round trip, and +// internal/rbacstore (the org/tenant/user/role Postgres storage those +// handlers need to look up a role from). Both depend on RBAC storage +// that wasn't built in task 3's scope and are called out as deferred +// rather than half-built -- see the task 5 summary. What IS wired end to +// end: minting and validating the RoleService credential /alerting +// presents, via -mint-service-token below. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/sentry/sentry/enterprise/internal/authhandler" + "github.com/sentry/sentry/enterprise/internal/config" + "github.com/sentry/sentry/enterprise/internal/oidc" + "github.com/sentry/sentry/enterprise/internal/session" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + cfg, err := config.Load() + if err != nil { + logger.Error("loading config", "error", err) + os.Exit(1) + } + + // -mint-service-token issues a RoleService credential and prints it + // to stdout, then exits -- an operator bootstrap step (run once, + // paste the output into /alerting's API_SERVICE_TOKEN), not an HTTP + // endpoint. Minting a service token has no session/cookie to check + // like a human login flow would, so this is deliberately an offline + // operator action gated by access to enterprise-auth's own + // environment/secrets, not a network-reachable endpoint. + mintServiceToken := flag.String("mint-service-token", "", "mint a RoleService credential for the named caller (e.g. \"alerting\") and exit") + // -healthcheck: same self-check mode as api/-healthcheck (see that + // binary's doc comment) -- enterprise-auth's image is distroless too. + healthcheck := flag.Bool("healthcheck", false, "self-check mode for Docker's HEALTHCHECK") + flag.Parse() + + if *healthcheck { + os.Exit(runHealthcheck(cfg.HTTPListenAddr)) + } + + sessionManager, err := session.NewManager(cfg.SessionSigningKey) + if err != nil { + logger.Error("constructing session manager", "error", err) + os.Exit(1) + } + + if *mintServiceToken != "" { + token, err := sessionManager.IssueServiceToken(*mintServiceToken) + if err != nil { + logger.Error("minting service token", "error", err) + os.Exit(1) + } + fmt.Println(token) + return + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if cfg.OIDC.IssuerURL != "" { + if _, err := oidc.New(ctx, oidc.Config{ + IssuerURL: cfg.OIDC.IssuerURL, ClientID: cfg.OIDC.ClientID, + ClientSecret: cfg.OIDC.ClientSecret, RedirectURL: cfg.OIDC.RedirectURL, + Scopes: []string{"email", "profile"}, + }); err != nil { + logger.Error("discovering OIDC issuer", "error", err) + os.Exit(1) + } + logger.Info("OIDC provider configured", "issuer", cfg.OIDC.IssuerURL) + } else { + logger.Info("OIDC not configured (OIDC_ISSUER_URL unset) -- skipping discovery") + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + features := authhandler.Features{ + OIDCEnabled: cfg.OIDC.IssuerURL != "", + SAMLEnabled: cfg.SAML.IDPMetadataURL != "", + } + authhandler.New(logger, sessionManager, features).RegisterRoutes(mux) + + srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: mux} + + errCh := make(chan error, 1) + go func() { + logger.Info("enterprise-auth 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) + } + } +} + +// runHealthcheck mirrors api/cmd/api/main.go's runHealthcheck exactly -- +// see that function's doc comment for why this execs the binary against +// itself rather than using an external tool. +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 +} diff --git a/enterprise/go.mod b/enterprise/go.mod new file mode 100644 index 0000000..032e70c --- /dev/null +++ b/enterprise/go.mod @@ -0,0 +1,25 @@ +module github.com/sentry/sentry/enterprise + +go 1.25.0 + +require ( + github.com/coreos/go-oidc/v3 v3.20.0 + github.com/crewjam/saml v0.5.1 + github.com/go-jose/go-jose/v4 v4.1.4 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/oauth2 v0.36.0 +) + +require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/russellhaering/goxmldsig v1.4.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/enterprise/go.sum b/enterprise/go.sum new file mode 100644 index 0000000..cfca1f6 --- /dev/null +++ b/enterprise/go.sum @@ -0,0 +1,70 @@ +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/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +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.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +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/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +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/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/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/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/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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +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/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/enterprise/internal/audit/audit.go b/enterprise/internal/audit/audit.go new file mode 100644 index 0000000..d124716 --- /dev/null +++ b/enterprise/internal/audit/audit.go @@ -0,0 +1,185 @@ +// Package audit implements the append-only, hash-chained query audit +// log described in /docs/phase-4-isolation-design.md's audit-logging +// section. Two independent defenses back the "no update/delete path +// from the application layer" requirement -- both verified against a +// live Postgres, not just written: audit_writer (this package's own +// Postgres role, via its own connection pool, never the shared `sentry` +// role every other store uses) has only INSERT+SELECT grants, and a +// BEFORE UPDATE OR DELETE trigger (metadata/migrations/0015-0016) +// rejects the operation for *any* role, including the table owner -- +// confirmed live: even `sentry` cannot UPDATE a row without first +// disabling the trigger, a privileged operation distinct from ordinary +// application access. +// +// The hash chain (prev_hash/row_hash) proves internal consistency -- +// detects tampering with existing rows -- but does not by itself prove +// truth against a privileged attacker who can rewrite the whole table +// and regenerate a self-consistent chain from row 1. See checkpoint.go +// for the external-anchoring half of that guarantee. +package audit + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Source string + +const ( + SourceAPI Source = "api" + SourceWeb Source = "web" + SourceCLI Source = "cli" + SourceAlerting Source = "alerting" +) + +type EventType string + +const ( + EventQuery EventType = "query" + EventRoleChange EventType = "role_change" + EventGrantChange EventType = "grant_change" + EventSSOConfigChange EventType = "sso_config_change" + EventSecretReveal EventType = "secret_reveal" +) + +type Status string + +const ( + StatusSuccess Status = "success" + StatusError Status = "error" +) + +// Entry is what a caller supplies. UserID is nil for system/alerting- +// sourced entries (see /docs/phase-4-isolation-design.md's alerting +// service-identity finding -- alerting evaluations are audited, but +// aren't attributable to a human user). +type Entry struct { + TenantID string + UserID *string + Source Source + EventType EventType + QueryText *string + RowCount *int + DurationMS *int + Status Status + ErrorMessage *string + Detail json.RawMessage +} + +// Record is a written entry plus the fields the store assigned. +type Record struct { + Entry + ID int64 + PrevHash *string + RowHash string +} + +// Store writes via a connection pool authenticated as the audit_writer +// role -- never the shared pool other stores in this repo use. Passing +// a pool opened with any other role's credentials silently defeats the +// grant-restriction half of this package's guarantee; there's no way +// for this package to verify its own pool's role at runtime, so this is +// an integration-time discipline documented here, not something this +// code can enforce on itself. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// advisoryLockKey serializes concurrent Append calls so two writers +// never read the same prev_hash and each compute a hash chained off it +// -- that would fork the chain. Arbitrary fixed value, held only for +// the duration of one transaction (pg_advisory_xact_lock releases +// automatically at commit/rollback). +const advisoryLockKey = 784129035 + +func (s *Store) Append(ctx context.Context, e Entry) (*Record, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("audit: beginning transaction: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", advisoryLockKey); err != nil { + return nil, fmt.Errorf("audit: acquiring serialization lock: %w", err) + } + + var prevHash *string + row := tx.QueryRow(ctx, "SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1") + if err := row.Scan(&prevHash); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("audit: reading previous row hash: %w", err) + } + + if len(e.Detail) == 0 { + e.Detail = json.RawMessage(`{}`) + } + rec := &Record{Entry: e, PrevHash: prevHash, RowHash: computeHash(prevHash, e)} + + err = tx.QueryRow(ctx, ` + INSERT INTO audit_log (tenant_id, user_id, source, event_type, query_text, row_count, + duration_ms, status, error_message, detail, prev_hash, row_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + e.TenantID, e.UserID, e.Source, e.EventType, e.QueryText, e.RowCount, + e.DurationMS, e.Status, e.ErrorMessage, e.Detail, prevHash, rec.RowHash, + ).Scan(&rec.ID) + if err != nil { + return nil, fmt.Errorf("audit: inserting row: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("audit: committing: %w", err) + } + return rec, nil +} + +// computeHash is deliberately a fixed, explicit field order (not "hash +// the JSON encoding," which is not guaranteed stable across Go versions +// or map key ordering) -- \x00 is used as a field separator since it +// cannot appear in any of these string fields in practice, and even if +// it somehow did, the goal here is deterministic tamper-detection +// against accidental/naive modification, not cryptographic +// collision-resistance against a chosen-plaintext adversary. +func computeHash(prevHash *string, e Entry) string { + h := sha256.New() + write := func(s string) { + h.Write([]byte(s)) + h.Write([]byte{0}) + } + write(deref(prevHash)) + write(e.TenantID) + write(deref(e.UserID)) + write(string(e.Source)) + write(string(e.EventType)) + write(deref(e.QueryText)) + write(intToStr(e.RowCount)) + write(intToStr(e.DurationMS)) + write(string(e.Status)) + write(deref(e.ErrorMessage)) + write(string(e.Detail)) + return hex.EncodeToString(h.Sum(nil)) +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func intToStr(n *int) string { + if n == nil { + return "" + } + return fmt.Sprintf("%d", *n) +} diff --git a/enterprise/internal/audit/checkpoint.go b/enterprise/internal/audit/checkpoint.go new file mode 100644 index 0000000..6bfdd59 --- /dev/null +++ b/enterprise/internal/audit/checkpoint.go @@ -0,0 +1,123 @@ +package audit + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// Checkpoint is a rolling hash over a range of audit_log rows, chained +// to the previous checkpoint the same way row_hash chains individual +// rows. The chain in audit_log alone only proves internal consistency: +// anyone with enough Postgres privilege to wipe the table can +// regenerate a perfectly self-consistent new chain from row 1. +// Checkpoints answer a different question -- "does what's in Postgres +// right now match what it was an hour ago" -- but only if they're +// written somewhere the same privileged actor can't also reach. That's +// CheckpointSink's job, not this package's: this package computes +// checkpoints correctly and hands them to a sink; it does not claim any +// particular sink is actually tamper-proof. +type Checkpoint struct { + FromID int64 + ToID int64 + PrevCheckpointHash string + Hash string + CreatedAt time.Time +} + +// CheckpointSink persists checkpoints somewhere external. FileSink +// (below) is a working, testable implementation appropriate for +// development -- a real deployment needs a sink that genuinely isn't +// reachable by whatever could tamper with Postgres (S3 with Object +// Lock, a separate append-only service, etc.), which this package +// deliberately does not implement: that's an operational/infrastructure +// decision, not something to hardcode a specific cloud vendor's SDK for +// without discussing the dependency first. +type CheckpointSink interface { + // LastCheckpoint returns the most recently written checkpoint, or + // nil if none exists yet. + LastCheckpoint(ctx context.Context) (*Checkpoint, error) + Write(ctx context.Context, cp Checkpoint) error +} + +// Checkpointer periodically rolls up new audit_log rows since the last +// checkpoint into a new one. +type Checkpointer struct { + store *Store + sink CheckpointSink +} + +func NewCheckpointer(store *Store, sink CheckpointSink) *Checkpointer { + return &Checkpointer{store: store, sink: sink} +} + +// Run computes and writes at most one new checkpoint covering every +// audit_log row added since the last one. Returns (nil, nil) if there's +// nothing new to checkpoint. Call on a schedule (e.g. hourly) from +// cmd/enterprise-auth -- this package doesn't run its own ticker, same +// "caller owns scheduling" shape as /alerting's evaluator. +func (c *Checkpointer) Run(ctx context.Context) (*Checkpoint, error) { + last, err := c.sink.LastCheckpoint(ctx) + if err != nil { + return nil, fmt.Errorf("audit: reading last checkpoint: %w", err) + } + + fromID := int64(1) + prevHash := "" + if last != nil { + fromID = last.ToID + 1 + prevHash = last.Hash + } + + rowHashes, maxID, err := c.store.rowHashesFrom(ctx, fromID) + if err != nil { + return nil, fmt.Errorf("audit: reading rows for checkpoint: %w", err) + } + if len(rowHashes) == 0 { + return nil, nil + } + + h := sha256.New() + h.Write([]byte(prevHash)) + for _, rh := range rowHashes { + h.Write([]byte{0}) + h.Write([]byte(rh)) + } + + cp := Checkpoint{ + FromID: fromID, ToID: maxID, + PrevCheckpointHash: prevHash, + Hash: hex.EncodeToString(h.Sum(nil)), + CreatedAt: time.Now().UTC(), + } + if err := c.sink.Write(ctx, cp); err != nil { + return nil, fmt.Errorf("audit: writing checkpoint: %w", err) + } + return &cp, nil +} + +// rowHashesFrom returns row_hash values for id >= fromID, in id order, +// plus the highest id seen (so the caller knows where the next +// checkpoint should resume). +func (s *Store) rowHashesFrom(ctx context.Context, fromID int64) ([]string, int64, error) { + rows, err := s.pool.Query(ctx, "SELECT id, row_hash FROM audit_log WHERE id >= $1 ORDER BY id ASC", fromID) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var hashes []string + var maxID int64 + for rows.Next() { + var id int64 + var hash string + if err := rows.Scan(&id, &hash); err != nil { + return nil, 0, err + } + hashes = append(hashes, hash) + maxID = id + } + return hashes, maxID, rows.Err() +} diff --git a/enterprise/internal/audit/filesink.go b/enterprise/internal/audit/filesink.go new file mode 100644 index 0000000..5231b45 --- /dev/null +++ b/enterprise/internal/audit/filesink.go @@ -0,0 +1,90 @@ +package audit + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// FileSink is a working CheckpointSink appropriate for development and +// testing -- appends one JSON line per checkpoint to a local file. +// **Not a real external-anchoring guarantee**: a local file on the same +// host as Postgres is reachable by exactly the kind of privileged actor +// checkpointing is meant to defend against. A production deployment +// needs a genuinely separate-trust-domain sink (S3 with Object Lock, a +// separate append-only service) -- deliberately not implemented here, +// per checkpoint.go's doc comment. +type FileSink struct { + path string +} + +func NewFileSink(path string) *FileSink { + return &FileSink{path: path} +} + +type fileSinkLine struct { + FromID int64 `json:"from_id"` + ToID int64 `json:"to_id"` + PrevCheckpointHash string `json:"prev_checkpoint_hash"` + Hash string `json:"hash"` + CreatedAt time.Time `json:"created_at"` +} + +func (f *FileSink) Write(_ context.Context, cp Checkpoint) error { + file, err := os.OpenFile(f.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("audit: opening checkpoint file: %w", err) + } + defer file.Close() + + line := fileSinkLine{ + FromID: cp.FromID, ToID: cp.ToID, + PrevCheckpointHash: cp.PrevCheckpointHash, Hash: cp.Hash, CreatedAt: cp.CreatedAt, + } + encoded, err := json.Marshal(line) + if err != nil { + return fmt.Errorf("audit: encoding checkpoint: %w", err) + } + if _, err := fmt.Fprintln(file, string(encoded)); err != nil { + return fmt.Errorf("audit: writing checkpoint: %w", err) + } + return nil +} + +func (f *FileSink) LastCheckpoint(_ context.Context) (*Checkpoint, error) { + file, err := os.Open(f.path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("audit: opening checkpoint file: %w", err) + } + defer file.Close() + + var lastLine string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if line := strings.TrimSpace(scanner.Text()); line != "" { + lastLine = line + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("audit: reading checkpoint file: %w", err) + } + if lastLine == "" { + return nil, nil + } + + var line fileSinkLine + if err := json.Unmarshal([]byte(lastLine), &line); err != nil { + return nil, fmt.Errorf("audit: decoding last checkpoint line: %w", err) + } + return &Checkpoint{ + FromID: line.FromID, ToID: line.ToID, + PrevCheckpointHash: line.PrevCheckpointHash, Hash: line.Hash, CreatedAt: line.CreatedAt, + }, nil +} diff --git a/enterprise/internal/audit/filesink_test.go b/enterprise/internal/audit/filesink_test.go new file mode 100644 index 0000000..ec46fab --- /dev/null +++ b/enterprise/internal/audit/filesink_test.go @@ -0,0 +1,42 @@ +package audit + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func TestFileSinkRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "checkpoints.jsonl") + sink := NewFileSink(path) + ctx := context.Background() + + none, err := sink.LastCheckpoint(ctx) + if err != nil { + t.Fatalf("LastCheckpoint on a nonexistent file: %v", err) + } + if none != nil { + t.Fatalf("expected nil for a nonexistent checkpoint file, got %+v", none) + } + + cp1 := Checkpoint{FromID: 1, ToID: 10, Hash: "hash1", CreatedAt: time.Now().UTC().Truncate(time.Second)} + if err := sink.Write(ctx, cp1); err != nil { + t.Fatalf("Write: %v", err) + } + cp2 := Checkpoint{FromID: 11, ToID: 20, PrevCheckpointHash: "hash1", Hash: "hash2", CreatedAt: time.Now().UTC().Truncate(time.Second)} + if err := sink.Write(ctx, cp2); err != nil { + t.Fatalf("Write: %v", err) + } + + last, err := sink.LastCheckpoint(ctx) + if err != nil { + t.Fatalf("LastCheckpoint: %v", err) + } + if last == nil { + t.Fatalf("expected a checkpoint, got nil") + } + if last.ToID != cp2.ToID || last.Hash != cp2.Hash { + t.Fatalf("got %+v, want the most recently written checkpoint %+v", last, cp2) + } +} diff --git a/enterprise/internal/audit/integration_test.go b/enterprise/internal/audit/integration_test.go new file mode 100644 index 0000000..e8f13af --- /dev/null +++ b/enterprise/internal/audit/integration_test.go @@ -0,0 +1,275 @@ +// Integration tests against a real Postgres, authenticated as the real +// audit_writer role -- this package's whole point is a set of guarantees +// (grants, the trigger, hash-chain correctness under concurrency) that a +// mocked pgxpool can't actually exercise. Skipped unless +// AUDIT_TEST_POSTGRES_ADDR is set; run via: +// +// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/enterprise \ +// -e AUDIT_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ +// -e AUDIT_TEST_POSTGRES_PASSWORD=audit-writer-dev-only \ +// -e AUDIT_TEST_ADMIN_PASSWORD=sentry-dev-only \ +// golang:1.25-alpine go test ./internal/audit/... -v +package audit + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func testPool(t *testing.T, user, password string) *pgxpool.Pool { + t.Helper() + addr := os.Getenv("AUDIT_TEST_POSTGRES_ADDR") + if addr == "" { + t.Skip("AUDIT_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") + } + dsn := fmt.Sprintf("postgres://%s:%s@%s/sentry_metadata", user, password, addr) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("opening pool: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +func cleanupAuditLog(t *testing.T, adminPool *pgxpool.Pool) { + t.Helper() + ctx := context.Background() + // Errors here were previously swallowed (_, _ =) -- that hid the + // real cause of a test failure (rows accumulating across test runs) + // behind what looked like a row-count/ID-assumption bug instead. + // Surface them. + if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable"); err != nil { + t.Fatalf("cleanup: disabling trigger: %v", err) + } + tag, err := adminPool.Exec(ctx, "DELETE FROM audit_log") + if err != nil { + t.Fatalf("cleanup: deleting rows: %v", err) + } + t.Logf("cleanup: deleted %d pre-existing rows", tag.RowsAffected()) + if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log ENABLE TRIGGER audit_log_immutable"); err != nil { + t.Fatalf("cleanup: re-enabling trigger: %v", err) + } +} + +func TestAppendAndVerifyChainRealPostgres(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) + + store := NewStore(writerPool) + ctx := context.Background() + + for i := 0; i < 5; i++ { + q := fmt.Sprintf("service=api | stats count %d", i) + rec, err := store.Append(ctx, Entry{ + TenantID: "default", Source: SourceAPI, EventType: EventQuery, + QueryText: &q, Status: StatusSuccess, + }) + if err != nil { + t.Fatalf("Append %d: %v", i, err) + } + if rec.RowHash == "" { + t.Fatalf("expected a non-empty row hash") + } + } + + result, err := store.VerifyChain(ctx) + if err != nil { + t.Fatalf("VerifyChain: %v", err) + } + if !result.OK { + t.Fatalf("expected an intact chain, got broken at id=%d after %d rows checked", result.FirstBadID, result.RowsChecked) + } + if result.RowsChecked != 5 { + t.Fatalf("RowsChecked = %d, want 5", result.RowsChecked) + } +} + +// TestVerifyChainDetectsTampering proves the chain actually catches an +// in-place row modification -- not just that VerifyChain runs without +// erroring on untampered data, which a bug returning OK unconditionally +// would also pass. +func TestVerifyChainDetectsTampering(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) + + store := NewStore(writerPool) + ctx := context.Background() + + var lastID int64 + for i := 0; i < 3; i++ { + q := "service=api" + rec, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess}) + if err != nil { + t.Fatalf("Append: %v", err) + } + lastID = rec.ID + } + + before, err := store.VerifyChain(ctx) + if err != nil || !before.OK { + t.Fatalf("expected chain to verify before tampering: ok=%v err=%v", before.OK, err) + } + + // Simulate tampering: a privileged actor disables the trigger (the + // same escape hatch confirmed live in the design doc's verification + // -- this is the "even the trigger doesn't stop a superuser" case) + // and rewrites a row's status without recomputing the hash chain. + if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable"); err != nil { + t.Fatalf("disabling trigger for the tamper simulation: %v", err) + } + if _, err := adminPool.Exec(ctx, "UPDATE audit_log SET status = 'error' WHERE id = $1", lastID); err != nil { + t.Fatalf("simulated tamper UPDATE: %v", err) + } + if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log ENABLE TRIGGER audit_log_immutable"); err != nil { + t.Fatalf("re-enabling trigger: %v", err) + } + + after, err := store.VerifyChain(ctx) + if err != nil { + t.Fatalf("VerifyChain after tampering: %v", err) + } + if after.OK { + t.Fatalf("expected VerifyChain to detect the tampered row, got OK") + } + if after.FirstBadID != lastID { + t.Fatalf("FirstBadID = %d, want %d", after.FirstBadID, lastID) + } +} + +// TestAppendConcurrentWritesProduceAValidChain exercises the advisory +// lock: without it, concurrent Append calls could read the same +// prev_hash and fork the chain. Real concurrency, real Postgres, not a +// unit test of the Go code alone. +func TestAppendConcurrentWritesProduceAValidChain(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) + + store := NewStore(writerPool) + ctx := context.Background() + + const n = 20 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + q := fmt.Sprintf("query-%d", i) + _, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess}) + errs <- err + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent Append failed: %v", err) + } + } + + result, err := store.VerifyChain(ctx) + if err != nil { + t.Fatalf("VerifyChain: %v", err) + } + if !result.OK { + t.Fatalf("expected an intact chain after %d concurrent appends, got broken at id=%d", n, result.FirstBadID) + } + if result.RowsChecked != n { + t.Fatalf("RowsChecked = %d, want %d", result.RowsChecked, n) + } +} + +// TestCheckpointerRun ties Store + FileSink together against real +// audit_log data: writes some rows, checkpoints, writes more, checkpoints +// again, and confirms the second checkpoint picks up exactly where the +// first left off (FromID = previous ToID + 1) with a hash chained off +// the previous checkpoint's hash. +func TestCheckpointerRun(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) + + store := NewStore(writerPool) + sink := NewFileSink(filepath.Join(t.TempDir(), "checkpoints.jsonl")) + checkpointer := NewCheckpointer(store, sink) + ctx := context.Background() + + // DELETE doesn't reset the BIGSERIAL sequence, so IDs are not + // guaranteed to start at 1 -- but Checkpoint.FromID is a *cursor + // position* (1, or the previous checkpoint's ToID+1), not "the + // lowest row ID that happens to still exist." In real usage audit_log + // never has gaps (append-only, protected by the immutability + // trigger), so those always coincide; here, this test's own + // destructive cleanupAuditLog between test functions creates a gap + // (rows from earlier tests were deleted, advancing the sequence) + // that real usage never produces -- so FromID is asserted against + // the cursor's own logic (1, since no prior checkpoint exists for + // this fresh FileSink), and ToID against the actual last ID Append + // returned. + var firstBatchLastID int64 + for i := 0; i < 3; i++ { + q := "first batch" + rec, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess}) + if err != nil { + t.Fatalf("Append: %v", err) + } + firstBatchLastID = rec.ID + } + + cp1, err := checkpointer.Run(ctx) + if err != nil { + t.Fatalf("first Run: %v", err) + } + if cp1 == nil { + t.Fatalf("expected a checkpoint after 3 rows, got nil") + } + if cp1.FromID != 1 || cp1.ToID != firstBatchLastID { + t.Fatalf("cp1 = %+v, want FromID=1 ToID=%d", cp1, firstBatchLastID) + } + + // Nothing new since the last checkpoint -- Run should be a no-op. + noop, err := checkpointer.Run(ctx) + if err != nil { + t.Fatalf("no-op Run: %v", err) + } + if noop != nil { + t.Fatalf("expected nil (nothing new to checkpoint), got %+v", noop) + } + + var secondBatchLastID int64 + for i := 0; i < 2; i++ { + q := "second batch" + rec, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess}) + if err != nil { + t.Fatalf("Append: %v", err) + } + secondBatchLastID = rec.ID + } + + cp2, err := checkpointer.Run(ctx) + if err != nil { + t.Fatalf("second Run: %v", err) + } + if cp2 == nil { + t.Fatalf("expected a second checkpoint, got nil") + } + if cp2.FromID != cp1.ToID+1 || cp2.ToID != secondBatchLastID { + t.Fatalf("cp2 = %+v, want FromID=%d ToID=%d", cp2, cp1.ToID+1, secondBatchLastID) + } + if cp2.PrevCheckpointHash != cp1.Hash { + t.Fatalf("cp2.PrevCheckpointHash = %q, want %q (chained to cp1)", cp2.PrevCheckpointHash, cp1.Hash) + } +} diff --git a/enterprise/internal/audit/verify.go b/enterprise/internal/audit/verify.go new file mode 100644 index 0000000..b5120dc --- /dev/null +++ b/enterprise/internal/audit/verify.go @@ -0,0 +1,100 @@ +package audit + +import ( + "context" + "fmt" +) + +// VerifyResult reports whether the chain is intact and, if not, the +// first row where it breaks -- everything after that point is +// untrustworthy regardless of whether later rows individually +// "verify," since a break means the chain was forked or rows were +// altered/removed at that point. +type VerifyResult struct { + OK bool + FirstBadID int64 // 0 if OK + RowsChecked int64 +} + +// VerifyChain walks audit_log in id order, recomputing each row's hash +// from its own fields plus the previous row's hash, and confirms it +// matches the stored row_hash and that prev_hash matches the actual +// previous row -- catching both in-place tampering (a row's fields +// changed, its stored row_hash no longer matches what recomputing it +// produces) and forgery (a row inserted with a prev_hash that doesn't +// match what actually preceded it). +// +// This proves internal consistency only. It cannot detect an attacker +// who deletes the whole table and replays a self-consistent chain from +// row 1 -- that's what checkpoint.go's external anchoring is for. Run +// both in the runbook/threat-model verification, not just this one. +func (s *Store) VerifyChain(ctx context.Context) (VerifyResult, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, user_id, source, event_type, query_text, row_count, + duration_ms, status, error_message, detail, prev_hash, row_hash + FROM audit_log ORDER BY id ASC`) + if err != nil { + return VerifyResult{}, fmt.Errorf("audit: querying for verification: %w", err) + } + defer rows.Close() + + var expectedPrevHash *string + var checked int64 + for rows.Next() { + var rec Record + if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.UserID, &rec.Source, &rec.EventType, + &rec.QueryText, &rec.RowCount, &rec.DurationMS, &rec.Status, &rec.ErrorMessage, + &rec.Detail, &rec.PrevHash, &rec.RowHash); err != nil { + return VerifyResult{}, fmt.Errorf("audit: scanning row for verification: %w", err) + } + checked++ + + if !hashPtrEqual(rec.PrevHash, expectedPrevHash) { + return VerifyResult{OK: false, FirstBadID: rec.ID, RowsChecked: checked}, nil + } + recomputed := computeHash(rec.PrevHash, rec.Entry) + if recomputed != rec.RowHash { + return VerifyResult{OK: false, FirstBadID: rec.ID, RowsChecked: checked}, nil + } + hash := rec.RowHash + expectedPrevHash = &hash + } + if err := rows.Err(); err != nil { + return VerifyResult{}, fmt.Errorf("audit: reading verification rows: %w", err) + } + + return VerifyResult{OK: true, RowsChecked: checked}, nil +} + +func hashPtrEqual(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// ListForTenant reads a tenant's audit trail, most recent first -- +// what a tenant Admin/Owner sees per /docs/phase-4-rbac-design.md's +// permission matrix. +func (s *Store) ListForTenant(ctx context.Context, tenantID string, limit int) ([]Record, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, user_id, source, event_type, query_text, row_count, + duration_ms, status, error_message, detail, prev_hash, row_hash + FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT $2`, tenantID, limit) + if err != nil { + return nil, fmt.Errorf("audit: listing for tenant: %w", err) + } + defer rows.Close() + + var out []Record + for rows.Next() { + var rec Record + if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.UserID, &rec.Source, &rec.EventType, + &rec.QueryText, &rec.RowCount, &rec.DurationMS, &rec.Status, &rec.ErrorMessage, + &rec.Detail, &rec.PrevHash, &rec.RowHash); err != nil { + return nil, fmt.Errorf("audit: scanning row: %w", err) + } + out = append(out, rec) + } + return out, rows.Err() +} diff --git a/enterprise/internal/authhandler/authhandler.go b/enterprise/internal/authhandler/authhandler.go new file mode 100644 index 0000000..b5e08a1 --- /dev/null +++ b/enterprise/internal/authhandler/authhandler.go @@ -0,0 +1,109 @@ +// Package authhandler implements enterprise-auth's POST /internal/authorize +// endpoint -- the HTTP side of the "network boundary, not import boundary" +// pattern api/internal/authz.HTTPAuthorizer calls into (see that package's +// doc comment). It resolves a caller's credentials (session cookie or +// service-token Bearer header) to an identity, using session.Manager for +// both -- a human session and /alerting's service token are both just +// signed tokens with a different Role claim, so one validation path +// handles both, and the Role claim (not which header carried it) is what +// determines whether the result looks like a human or a service identity. +package authhandler + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + + "github.com/sentry/sentry/enterprise/internal/session" +) + +// SessionCookieName matches the name api/internal/authz.HTTPAuthorizer's +// tests and doc comments already assume ("sentry_session"). +const SessionCookieName = "sentry_session" + +// Features reports which SSO mechanisms are configured -- the response +// shape /docs/phase-4-rbac-design.md's "Web UI boundary" section commits +// to ({"sso_configured", "oidc_enabled", "saml_enabled"}), so web can +// show/hide enterprise settings sections as a runtime capability check +// rather than a conditional import. +type Features struct { + OIDCEnabled bool + SAMLEnabled bool +} + +type Handler struct { + logger *slog.Logger + manager *session.Manager + features Features +} + +func New(logger *slog.Logger, manager *session.Manager, features Features) *Handler { + return &Handler{logger: logger, manager: manager, features: features} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /internal/authorize", h.handleAuthorize) + mux.HandleFunc("GET /auth/features", h.handleFeatures) +} + +type featuresResponse struct { + SSOConfigured bool `json:"sso_configured"` + OIDCEnabled bool `json:"oidc_enabled"` + SAMLEnabled bool `json:"saml_enabled"` +} + +func (h *Handler) handleFeatures(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(featuresResponse{ + SSOConfigured: h.features.OIDCEnabled || h.features.SAMLEnabled, + OIDCEnabled: h.features.OIDCEnabled, + SAMLEnabled: h.features.SAMLEnabled, + }) +} + +type authorizeResponse struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Role string `json:"role"` +} + +// handleAuthorize checks the Authorization Bearer header first (the +// service-token path /alerting uses), falling back to the session +// cookie (the human path a browser sends). Both resolve through the same +// session.Manager.Validate -- see the package doc comment for why that's +// safe: the Role claim inside the token is what determines the result, +// not which header it arrived on. +func (h *Handler) handleAuthorize(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r.Header.Get("Authorization")) + if token == "" { + if c, err := r.Cookie(SessionCookieName); err == nil { + token = c.Value + } + } + if token == "" { + http.Error(w, "no credentials presented", http.StatusUnauthorized) + return + } + + claims, err := h.manager.Validate(token) + if err != nil { + http.Error(w, "invalid or expired credentials", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authorizeResponse{ + TenantID: claims.TenantID, + UserID: claims.UserID, + Role: claims.Role, + }) +} + +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimPrefix(header, prefix) +} diff --git a/enterprise/internal/authhandler/authhandler_test.go b/enterprise/internal/authhandler/authhandler_test.go new file mode 100644 index 0000000..140bde0 --- /dev/null +++ b/enterprise/internal/authhandler/authhandler_test.go @@ -0,0 +1,175 @@ +package authhandler + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sentry/sentry/enterprise/internal/session" +) + +func testHandler(t *testing.T) (*Handler, *session.Manager) { + t.Helper() + m, err := session.NewManager([]byte("this-is-a-32-byte-test-signing-key!")) + if err != nil { + t.Fatalf("session.NewManager: %v", err) + } + return New(slog.New(slog.NewTextHandler(io.Discard, nil)), m, Features{}), m +} + +func doAuthorize(t *testing.T, h *Handler, mutate func(*http.Request)) *httptest.ResponseRecorder { + t.Helper() + mux := http.NewServeMux() + h.RegisterRoutes(mux) + req := httptest.NewRequest(http.MethodPost, "/internal/authorize", nil) + if mutate != nil { + mutate(req) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestAuthorizeViaServiceToken(t *testing.T) { + h, m := testHandler(t) + token, err := m.IssueServiceToken("alerting") + if err != nil { + t.Fatalf("IssueServiceToken: %v", err) + } + rec := doAuthorize(t, h, func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body authorizeResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if body.Role != "service" || body.TenantID != "" || body.UserID != "" { + t.Fatalf("unexpected response: %+v", body) + } +} + +func TestAuthorizeViaSessionCookie(t *testing.T) { + h, m := testHandler(t) + token, err := m.IssueUserSession("acme", "u1", "editor") + if err != nil { + t.Fatalf("IssueUserSession: %v", err) + } + rec := doAuthorize(t, h, func(r *http.Request) { + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token}) + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body authorizeResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if body.TenantID != "acme" || body.UserID != "u1" || body.Role != "editor" { + t.Fatalf("unexpected response: %+v", body) + } +} + +func TestAuthorizeBearerTakesPrecedenceOverCookie(t *testing.T) { + h, m := testHandler(t) + serviceToken, err := m.IssueServiceToken("alerting") + if err != nil { + t.Fatalf("IssueServiceToken: %v", err) + } + sessionToken, err := m.IssueUserSession("acme", "u1", "viewer") + if err != nil { + t.Fatalf("IssueUserSession: %v", err) + } + rec := doAuthorize(t, h, func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+serviceToken) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: sessionToken}) + }) + var body authorizeResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if body.Role != "service" { + t.Fatalf("expected the Bearer service token to win, got role %q", body.Role) + } +} + +func TestAuthorizeNoCredentialsIsUnauthorized(t *testing.T) { + h, _ := testHandler(t) + rec := doAuthorize(t, h, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestAuthorizeInvalidTokenIsUnauthorized(t *testing.T) { + h, _ := testHandler(t) + rec := doAuthorize(t, h, func(r *http.Request) { + r.Header.Set("Authorization", "Bearer not-a-real-token") + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestFeaturesReflectsConfiguredMechanisms(t *testing.T) { + m, err := session.NewManager([]byte("this-is-a-32-byte-test-signing-key!")) + if err != nil { + t.Fatalf("session.NewManager: %v", err) + } + h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), m, Features{OIDCEnabled: true, SAMLEnabled: false}) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/features", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body featuresResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if !body.SSOConfigured || !body.OIDCEnabled || body.SAMLEnabled { + t.Fatalf("unexpected features response: %+v", body) + } +} + +func TestFeaturesAllFalseWhenNothingConfigured(t *testing.T) { + h, _ := testHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/features", nil)) + + var body featuresResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if body.SSOConfigured || body.OIDCEnabled || body.SAMLEnabled { + t.Fatalf("expected all-false features when nothing is configured, got %+v", body) + } +} + +func TestAuthorizeTokenFromWrongManagerIsUnauthorized(t *testing.T) { + h, _ := testHandler(t) + otherManager, err := session.NewManager([]byte("a-completely-different-32-byte-key!")) + if err != nil { + t.Fatalf("session.NewManager: %v", err) + } + token, err := otherManager.IssueServiceToken("alerting") + if err != nil { + t.Fatalf("IssueServiceToken: %v", err) + } + rec := doAuthorize(t, h, func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} diff --git a/enterprise/internal/config/config.go b/enterprise/internal/config/config.go new file mode 100644 index 0000000..6c687b3 --- /dev/null +++ b/enterprise/internal/config/config.go @@ -0,0 +1,91 @@ +// Package config loads enterprise-auth's configuration from environment +// variables, same convention as every other Go service in this repo. +package config + +import ( + "fmt" + "os" +) + +type Config struct { + HTTPListenAddr string + Postgres PostgresConfig + OIDC OIDCConfig + SAML SAMLConfig + SessionSigningKey []byte +} + +type PostgresConfig struct { + Addr string + Database string + Username string + Password string +} + +// OIDCConfig is optional -- a deployment might configure OIDC, SAML, +// both, or (during early rollout) neither yet. Load() doesn't fail if +// these are unset; internal/oidc.New is only called once IssuerURL is +// actually present. +type OIDCConfig struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string +} + +// SAMLConfig is likewise optional. Note this only records *presence* -- +// enough for /auth/features (internal/authhandler) to report +// saml_enabled -- it does not itself fetch/parse IDPMetadataURL into the +// *saml.EntityDescriptor internal/saml.New requires; that fetch (and the +// login/ACS HTTP handlers that would use it) is deferred, same as OIDC's +// login/callback handlers -- see cmd/enterprise-auth/main.go's doc +// comment. +type SAMLConfig struct { + EntityID string + ACSURL string + IDPMetadataURL string +} + +func Load() (Config, error) { + cfg := Config{ + HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"), + Postgres: PostgresConfig{ + Addr: getenv("POSTGRES_ADDR", "localhost:5432"), + Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), + Username: getenv("POSTGRES_USERNAME", "sentry"), + Password: getenv("POSTGRES_PASSWORD", ""), + }, + OIDC: OIDCConfig{ + IssuerURL: getenv("OIDC_ISSUER_URL", ""), + ClientID: getenv("OIDC_CLIENT_ID", ""), + ClientSecret: getenv("OIDC_CLIENT_SECRET", ""), + RedirectURL: getenv("OIDC_REDIRECT_URL", ""), + }, + SAML: SAMLConfig{ + EntityID: getenv("SAML_ENTITY_ID", ""), + ACSURL: getenv("SAML_ACS_URL", ""), + IDPMetadataURL: getenv("SAML_IDP_METADATA_URL", ""), + }, + } + + // Required, unlike OIDC/SAML above: every enterprise-auth deployment + // issues and validates session/service tokens (internal/session), + // even one that hasn't configured any IdP yet. 32 bytes matches + // internal/session.MinSigningKeyBytes -- not imported here to avoid + // a config->session dependency for one constant, but the two values + // must be kept in sync. + signingKey := getenv("ENTERPRISE_SESSION_SIGNING_KEY", "") + if len(signingKey) < 32 { + return Config{}, fmt.Errorf("ENTERPRISE_SESSION_SIGNING_KEY must be set to at least 32 bytes (got %d)", len(signingKey)) + } + cfg.SessionSigningKey = []byte(signingKey) + + return cfg, nil +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/enterprise/internal/oidc/oidc.go b/enterprise/internal/oidc/oidc.go new file mode 100644 index 0000000..60b6f6b --- /dev/null +++ b/enterprise/internal/oidc/oidc.go @@ -0,0 +1,116 @@ +// Package oidc wires coreos/go-oidc into a small relying-party client: +// discovery, the login redirect, and code exchange + ID token +// verification. Deliberately thin -- this package answers "is this +// person who they say they are, and what's their email/subject" and +// nothing about tenants/roles; internal/session maps a verified identity +// to a tenant.ID via tenant.TrustFromValidatedSession, kept as a +// separate concern per /docs/phase-4-isolation-design.md. +package oidc + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + + goidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type Config struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string + // Scopes beyond the mandatory "openid" -- "email" and "profile" are + // the common additions IdPs support without extra configuration. + Scopes []string +} + +// Provider wraps a discovered OIDC issuer and the oauth2 config derived +// from it. Construction does real network discovery (GET +// {issuer}/.well-known/openid-configuration) -- see New's doc comment. +type Provider struct { + verifier *goidc.IDTokenVerifier + oauth2 oauth2.Config +} + +// Claims is the subset of ID token claims Sentry actually uses. Extend +// deliberately, not by passing the raw claim map further up the stack -- +// every field added here is a field internal/session has to decide how +// to trust. +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +// New performs OIDC discovery against cfg.IssuerURL. Real network I/O -- +// call once at startup (or lazily, cached), not per request. +func New(ctx context.Context, cfg Config) (*Provider, error) { + if cfg.IssuerURL == "" || cfg.ClientID == "" || cfg.RedirectURL == "" { + return nil, fmt.Errorf("oidc: IssuerURL, ClientID, and RedirectURL are required") + } + + issuer, err := goidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc: discovering issuer %q: %w", cfg.IssuerURL, err) + } + + scopes := append([]string{goidc.ScopeOpenID}, cfg.Scopes...) + + return &Provider{ + verifier: issuer.Verifier(&goidc.Config{ClientID: cfg.ClientID}), + oauth2: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: issuer.Endpoint(), + Scopes: scopes, + }, + }, nil +} + +// NewState generates a CSRF-protection state value for the login +// redirect. The caller is responsible for storing it (session/cookie) +// and comparing it against what comes back to the callback endpoint -- +// this package doesn't hold any server-side state itself. +func NewState() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("oidc: generating state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// AuthCodeURL is where the browser gets redirected to start login. +func (p *Provider) AuthCodeURL(state string) string { + return p.oauth2.AuthCodeURL(state) +} + +// Exchange trades an authorization code for tokens and returns the +// verified ID token's claims. Verification (signature, issuer, +// audience, expiry) happens inside p.verifier.Verify -- this is the +// step that actually establishes trust, not just "we got a token back." +func (p *Provider) Exchange(ctx context.Context, code string) (*Claims, error) { + token, err := p.oauth2.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("oidc: exchanging code: %w", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return nil, fmt.Errorf("oidc: token response had no id_token") + } + + idToken, err := p.verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("oidc: verifying id_token: %w", err) + } + + var claims Claims + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("oidc: decoding claims: %w", err) + } + return &claims, nil +} diff --git a/enterprise/internal/oidc/oidc_test.go b/enterprise/internal/oidc/oidc_test.go new file mode 100644 index 0000000..4ba524e --- /dev/null +++ b/enterprise/internal/oidc/oidc_test.go @@ -0,0 +1,75 @@ +package oidc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewRejectsMissingConfig(t *testing.T) { + _, err := New(context.Background(), Config{}) + if err == nil { + t.Fatalf("expected an error for an empty config") + } +} + +// TestNewDiscoversRealIssuer spins up a real HTTP server serving a +// minimal valid OIDC discovery document and confirms New() actually +// performs discovery against it successfully -- not just "the code +// compiles and looks plausible." Doesn't cover the full Exchange() flow +// (needs a signed JWKS/token response, real crypto scaffolding better +// suited to task 5's end-to-end auth integration tests), but discovery +// is exactly the step that would silently break on a URL-construction or +// JSON-shape mistake, so it's worth actually running. +func TestNewDiscoversRealIssuer(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "userinfo_endpoint": srv.URL + "/userinfo", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{}}) + }) + + p, err := New(context.Background(), Config{ + IssuerURL: srv.URL, ClientID: "sentry", ClientSecret: "secret", RedirectURL: "http://localhost/callback", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if p.AuthCodeURL("state123") == "" { + t.Fatalf("expected a non-empty auth code URL") + } +} + +func TestNewStateIsNonEmptyAndUnique(t *testing.T) { + a, err := NewState() + if err != nil { + t.Fatalf("NewState: %v", err) + } + b, err := NewState() + if err != nil { + t.Fatalf("NewState: %v", err) + } + if a == "" || b == "" { + t.Fatalf("expected non-empty state values") + } + if a == b { + t.Fatalf("expected two calls to NewState to produce different values") + } +} diff --git a/enterprise/internal/rbacstore/rbacstore.go b/enterprise/internal/rbacstore/rbacstore.go new file mode 100644 index 0000000..46a853f --- /dev/null +++ b/enterprise/internal/rbacstore/rbacstore.go @@ -0,0 +1,254 @@ +// Package rbacstore is the pgx-backed CRUD layer over the tenant/user/ +// role schema (metadata/migrations/0017-0021) described in +// /docs/phase-4-rbac-design.md: users (global SSO identity), tenants, +// and tenant_memberships (per-tenant role). It uses the same shared +// "sentry" Postgres role/pool every other metadata store does (unlike +// enterprise/internal/audit's deliberately separate, narrower-granted +// pool) -- ordinary read/write CRUD on control-plane config, not an +// append-only ledger, so it has no analogous reason to restrict its own +// write access. +// +// This package is the storage building block a future OIDC/SAML login +// HTTP handler would call to resolve "which tenant/role does this SSO +// identity map to" and issue a session (internal/session) accordingly -- +// that handler itself isn't built yet (see cmd/enterprise-auth/main.go's +// doc comment), so today rbacstore's only production caller is +// -mint-service-token's future tenant-aware successor and its own tests. +// dashboard_permissions and data_sources (also part of the schema) don't +// have CRUD here yet -- no caller needs them until dashboards' handler +// wiring reads per-resource grants, named as deferred in task 5's +// summary. +package rbacstore + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound is returned by Get-shaped methods when the row doesn't exist. +var ErrNotFound = errors.New("rbacstore: not found") + +type User struct { + ID string + Email string + DisplayName string + SSOSubject string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Tenant struct { + ID string + DisplayName string + Status string + OwnerUserID string // empty until a first Owner is assigned + CreatedAt time.Time + UpdatedAt time.Time +} + +// Role mirrors api/internal/authz.Role's string values, kept as a plain +// string here rather than importing authz -- rbacstore is enterprise +// code and api/internal/authz is core; enterprise may depend on +// nothing-shaped-like-an-import-from-core per the module boundary +// (see /docs/phase-4-isolation-design.md), even though the reverse +// (core importing enterprise) is the one hack/check-tenant-boundary.sh +// actually enforces. Values must stay in sync with authz.Role's +// constants by convention, verified by rbacstore_test.go. +type Role string + +const ( + RoleViewer Role = "viewer" + RoleEditor Role = "editor" + RoleAdmin Role = "admin" + RoleOwner Role = "owner" +) + +type Membership struct { + TenantID string + UserID string + Role Role +} + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// UpsertUserBySSO finds an existing user by ssoSubject, falling back to +// email (covers a user pre-provisioned by an Admin before their first +// SSO login -- see 0017_create_users.sql's ssoSubject nullability +// comment), or creates a new row. This is the one place a user's +// display_name/ssoSubject are refreshed from IdP claims on every login, +// matching a typical SSO-managed-identity pattern (the IdP is the +// source of truth for name/email; role assignment stays local, per +// /docs/phase-4-rbac-design.md's "manual role assignment" baseline). +func (s *Store) UpsertUserBySSO(ctx context.Context, ssoSubject, email, displayName string) (*User, error) { + if ssoSubject == "" || email == "" { + return nil, fmt.Errorf("rbacstore: ssoSubject and email are required") + } + + var u User + row := s.pool.QueryRow(ctx, ` + INSERT INTO users (id, email, display_name, sso_subject) + VALUES ($1, $2, $3, $4) + ON CONFLICT (email) DO UPDATE + SET display_name = EXCLUDED.display_name, + sso_subject = EXCLUDED.sso_subject, + updated_at = now() + RETURNING id, email, display_name, sso_subject, created_at, updated_at`, + uuid.NewString(), email, displayName, ssoSubject) + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.SSOSubject, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("rbacstore: upserting user: %w", err) + } + return &u, nil +} + +func (s *Store) GetUser(ctx context.Context, id string) (*User, error) { + var u User + row := s.pool.QueryRow(ctx, ` + SELECT id, email, display_name, sso_subject, created_at, updated_at + FROM users WHERE id = $1`, id) + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.SSOSubject, &u.CreatedAt, &u.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("rbacstore: getting user: %w", err) + } + return &u, nil +} + +// CreateTenant inserts a new tenant in 'provisioning' status -- callers +// (future tenant-provisioning code, per /docs/phase-4-isolation-design.md's +// ordered provisioning state machine) move it to 'active' via +// SetTenantStatus only after ClickHouse/Tantivy provisioning succeeds. +func (s *Store) CreateTenant(ctx context.Context, id, displayName string) (*Tenant, error) { + if id == "" || displayName == "" { + return nil, fmt.Errorf("rbacstore: id and displayName are required") + } + var t Tenant + row := s.pool.QueryRow(ctx, ` + INSERT INTO tenants (id, display_name, status) + VALUES ($1, $2, 'provisioning') + RETURNING id, display_name, status, coalesce(owner_user_id::text, ''), created_at, updated_at`, + id, displayName) + if err := row.Scan(&t.ID, &t.DisplayName, &t.Status, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, fmt.Errorf("rbacstore: creating tenant: %w", err) + } + return &t, nil +} + +func (s *Store) GetTenant(ctx context.Context, id string) (*Tenant, error) { + var t Tenant + row := s.pool.QueryRow(ctx, ` + SELECT id, display_name, status, coalesce(owner_user_id::text, ''), created_at, updated_at + FROM tenants WHERE id = $1`, id) + if err := row.Scan(&t.ID, &t.DisplayName, &t.Status, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("rbacstore: getting tenant: %w", err) + } + return &t, nil +} + +// SetTenantStatus is the only way a tenant's status column changes -- +// every tenant-resolution path elsewhere must re-check this via +// GetTenant, never cache/assume 'active', per +// /docs/phase-4-isolation-design.md's provisioning gate. +func (s *Store) SetTenantStatus(ctx context.Context, id, status string) error { + tag, err := s.pool.Exec(ctx, `UPDATE tenants SET status = $2, updated_at = now() WHERE id = $1`, id, status) + if err != nil { + return fmt.Errorf("rbacstore: setting tenant status: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// SetOwner sets a tenant's owner_user_id -- separate from +// SetMembership because the schema's Owner is a tenant-level column +// (exactly one, non-removable except by itself/platform break-glass per +// /docs/phase-4-rbac-design.md), not just the highest tenant_memberships +// role. Callers are expected to also call SetMembership(tenantID, +// userID, RoleOwner) so the membership table and this column agree -- +// this package doesn't wrap both in one method because tenant creation +// (no owner yet) and ownership transfer (existing owner changes) are +// different call sites with different validation needs. +func (s *Store) SetOwner(ctx context.Context, tenantID, userID string) error { + tag, err := s.pool.Exec(ctx, `UPDATE tenants SET owner_user_id = $2, updated_at = now() WHERE id = $1`, tenantID, userID) + if err != nil { + return fmt.Errorf("rbacstore: setting tenant owner: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// SetMembership upserts a user's role for a tenant -- the sole mutation +// path for tenant_memberships, so every role change naturally funnels +// through one method a future audit-log hook (EventRoleChange, see +// enterprise/internal/audit) can wrap. +func (s *Store) SetMembership(ctx context.Context, tenantID, userID string, role Role) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO tenant_memberships (id, tenant_id, user_id, role) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET role = EXCLUDED.role, updated_at = now()`, + uuid.NewString(), tenantID, userID, string(role)) + if err != nil { + return fmt.Errorf("rbacstore: setting membership: %w", err) + } + return nil +} + +func (s *Store) GetMembership(ctx context.Context, tenantID, userID string) (*Membership, error) { + var m Membership + var role string + row := s.pool.QueryRow(ctx, ` + SELECT tenant_id, user_id, role FROM tenant_memberships + WHERE tenant_id = $1 AND user_id = $2`, tenantID, userID) + if err := row.Scan(&m.TenantID, &m.UserID, &role); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("rbacstore: getting membership: %w", err) + } + m.Role = Role(role) + return &m, nil +} + +// ListMembershipsForUser supports "which tenants can this user act in, +// and at what role" -- the shape a login/session-issuance handler needs +// when a user belongs to more than one tenant and must pick (or be +// asked to pick) which one to act as for a given session. +func (s *Store) ListMembershipsForUser(ctx context.Context, userID string) ([]Membership, error) { + rows, err := s.pool.Query(ctx, ` + SELECT tenant_id, user_id, role FROM tenant_memberships WHERE user_id = $1 ORDER BY tenant_id`, userID) + if err != nil { + return nil, fmt.Errorf("rbacstore: listing memberships: %w", err) + } + defer rows.Close() + + var out []Membership + for rows.Next() { + var m Membership + var role string + if err := rows.Scan(&m.TenantID, &m.UserID, &role); err != nil { + return nil, fmt.Errorf("rbacstore: scanning membership: %w", err) + } + m.Role = Role(role) + out = append(out, m) + } + return out, rows.Err() +} diff --git a/enterprise/internal/rbacstore/rbacstore_test.go b/enterprise/internal/rbacstore/rbacstore_test.go new file mode 100644 index 0000000..3d8de17 --- /dev/null +++ b/enterprise/internal/rbacstore/rbacstore_test.go @@ -0,0 +1,227 @@ +// Integration tests against a real Postgres -- rbacstore's whole job is +// SQL (upserts, FK constraints, unique constraints on +// (tenant_id, user_id)/(dashboard_id, user_id)), so a mocked pool +// wouldn't actually exercise it. Skipped unless RBACSTORE_TEST_POSTGRES_ADDR +// is set; run via: +// +// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/enterprise \ +// -e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ +// -e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \ +// golang:1.25-alpine go test ./internal/rbacstore/... -v +package rbacstore + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func testStore(t *testing.T) *Store { + t.Helper() + addr := os.Getenv("RBACSTORE_TEST_POSTGRES_ADDR") + if addr == "" { + t.Skip("RBACSTORE_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") + } + password := os.Getenv("RBACSTORE_TEST_POSTGRES_PASSWORD") + dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("opening pool: %v", err) + } + t.Cleanup(pool.Close) + return NewStore(pool) +} + +// uniqueTestTenant/uniqueTestEmail avoid collisions across repeated test +// runs against a persistent dev Postgres (no cleanup step deletes rows, +// unlike audit's cleanupAuditLog -- these rows are meant to look like +// real, retained control-plane data, not scratch state). +func uniqueSuffix() string { + return uuid.NewString()[:8] +} + +func TestCreateAndGetTenant(t *testing.T) { + s := testStore(t) + id := "test-tenant-" + uniqueSuffix() + + created, err := s.CreateTenant(context.Background(), id, "Test Tenant") + if err != nil { + t.Fatalf("CreateTenant: %v", err) + } + if created.Status != "provisioning" { + t.Fatalf("new tenant status = %q, want provisioning", created.Status) + } + if created.OwnerUserID != "" { + t.Fatalf("new tenant owner = %q, want empty until an Owner is assigned", created.OwnerUserID) + } + + got, err := s.GetTenant(context.Background(), id) + if err != nil { + t.Fatalf("GetTenant: %v", err) + } + if got.DisplayName != "Test Tenant" { + t.Fatalf("DisplayName = %q, want %q", got.DisplayName, "Test Tenant") + } +} + +func TestGetTenantNotFound(t *testing.T) { + s := testStore(t) + if _, err := s.GetTenant(context.Background(), "does-not-exist-"+uniqueSuffix()); err != ErrNotFound { + t.Fatalf("GetTenant error = %v, want ErrNotFound", err) + } +} + +func TestSetTenantStatusGatesProvisioning(t *testing.T) { + s := testStore(t) + id := "test-tenant-" + uniqueSuffix() + if _, err := s.CreateTenant(context.Background(), id, "Test Tenant"); err != nil { + t.Fatalf("CreateTenant: %v", err) + } + if err := s.SetTenantStatus(context.Background(), id, "active"); err != nil { + t.Fatalf("SetTenantStatus: %v", err) + } + got, err := s.GetTenant(context.Background(), id) + if err != nil { + t.Fatalf("GetTenant: %v", err) + } + if got.Status != "active" { + t.Fatalf("Status = %q, want active", got.Status) + } +} + +func TestSetTenantStatusNotFound(t *testing.T) { + s := testStore(t) + if err := s.SetTenantStatus(context.Background(), "does-not-exist-"+uniqueSuffix(), "active"); err != ErrNotFound { + t.Fatalf("SetTenantStatus error = %v, want ErrNotFound", err) + } +} + +func TestUpsertUserBySSOCreatesThenUpdates(t *testing.T) { + s := testStore(t) + email := "user-" + uniqueSuffix() + "@example.com" + + u1, err := s.UpsertUserBySSO(context.Background(), "sub-1", email, "First Name") + if err != nil { + t.Fatalf("UpsertUserBySSO (create): %v", err) + } + if u1.SSOSubject != "sub-1" || u1.DisplayName != "First Name" { + t.Fatalf("unexpected user: %+v", u1) + } + + // Second call with the same email (as if the IdP changed the + // display name, or re-issued a new "sub") must update the same row, + // not create a second one -- email is the natural key here. + u2, err := s.UpsertUserBySSO(context.Background(), "sub-2", email, "Updated Name") + if err != nil { + t.Fatalf("UpsertUserBySSO (update): %v", err) + } + if u2.ID != u1.ID { + t.Fatalf("upsert created a second row: first ID %q, second ID %q", u1.ID, u2.ID) + } + if u2.SSOSubject != "sub-2" || u2.DisplayName != "Updated Name" { + t.Fatalf("upsert did not refresh IdP-sourced fields: %+v", u2) + } +} + +func TestSetOwnerAndMembershipRoundTrip(t *testing.T) { + s := testStore(t) + ctx := context.Background() + tenantID := "test-tenant-" + uniqueSuffix() + email := "owner-" + uniqueSuffix() + "@example.com" + + if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil { + t.Fatalf("CreateTenant: %v", err) + } + user, err := s.UpsertUserBySSO(ctx, "sub-owner", email, "Owner") + if err != nil { + t.Fatalf("UpsertUserBySSO: %v", err) + } + + if err := s.SetMembership(ctx, tenantID, user.ID, RoleOwner); err != nil { + t.Fatalf("SetMembership: %v", err) + } + if err := s.SetOwner(ctx, tenantID, user.ID); err != nil { + t.Fatalf("SetOwner: %v", err) + } + + tenant, err := s.GetTenant(ctx, tenantID) + if err != nil { + t.Fatalf("GetTenant: %v", err) + } + if tenant.OwnerUserID != user.ID { + t.Fatalf("tenant OwnerUserID = %q, want %q", tenant.OwnerUserID, user.ID) + } + + membership, err := s.GetMembership(ctx, tenantID, user.ID) + if err != nil { + t.Fatalf("GetMembership: %v", err) + } + if membership.Role != RoleOwner { + t.Fatalf("membership role = %q, want owner", membership.Role) + } + + // Re-setting the membership (e.g. a role change) must update in + // place, not create a duplicate row for the same (tenant, user). + if err := s.SetMembership(ctx, tenantID, user.ID, RoleAdmin); err != nil { + t.Fatalf("SetMembership (update): %v", err) + } + membership, err = s.GetMembership(ctx, tenantID, user.ID) + if err != nil { + t.Fatalf("GetMembership after update: %v", err) + } + if membership.Role != RoleAdmin { + t.Fatalf("membership role after update = %q, want admin", membership.Role) + } +} + +func TestGetMembershipNotFound(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) + } + user, err := s.UpsertUserBySSO(ctx, "sub-no-membership", "no-membership-"+uniqueSuffix()+"@example.com", "Nobody") + if err != nil { + t.Fatalf("UpsertUserBySSO: %v", err) + } + if _, err := s.GetMembership(ctx, tenantID, user.ID); err != ErrNotFound { + t.Fatalf("GetMembership error = %v, want ErrNotFound", err) + } +} + +func TestListMembershipsForUserAcrossTenants(t *testing.T) { + s := testStore(t) + ctx := context.Background() + user, err := s.UpsertUserBySSO(ctx, "sub-multi", "multi-"+uniqueSuffix()+"@example.com", "Multi Tenant User") + if err != nil { + t.Fatalf("UpsertUserBySSO: %v", err) + } + + tenantA := "test-tenant-a-" + uniqueSuffix() + tenantB := "test-tenant-b-" + uniqueSuffix() + if _, err := s.CreateTenant(ctx, tenantA, "Tenant A"); err != nil { + t.Fatalf("CreateTenant A: %v", err) + } + if _, err := s.CreateTenant(ctx, tenantB, "Tenant B"); err != nil { + t.Fatalf("CreateTenant B: %v", err) + } + if err := s.SetMembership(ctx, tenantA, user.ID, RoleViewer); err != nil { + t.Fatalf("SetMembership A: %v", err) + } + if err := s.SetMembership(ctx, tenantB, user.ID, RoleAdmin); err != nil { + t.Fatalf("SetMembership B: %v", err) + } + + memberships, err := s.ListMembershipsForUser(ctx, user.ID) + if err != nil { + t.Fatalf("ListMembershipsForUser: %v", err) + } + if len(memberships) != 2 { + t.Fatalf("got %d memberships, want 2: %+v", len(memberships), memberships) + } +} diff --git a/enterprise/internal/saml/saml.go b/enterprise/internal/saml/saml.go new file mode 100644 index 0000000..213e80e --- /dev/null +++ b/enterprise/internal/saml/saml.go @@ -0,0 +1,173 @@ +// Package saml wires crewjam/saml into a small SP (service provider) +// client: build the login redirect, and validate/parse an incoming +// assertion. Deliberately not using crewjam's samlsp.Middleware, which +// owns its own session/cookie handling -- Sentry's session concept lives +// in internal/session, one layer up, so this package only does the SAML +// protocol mechanics (XML signing/parsing), per the explicit instruction +// not to hand-roll that crypto. +package saml + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net/http" + "net/url" + "time" + + "github.com/crewjam/saml" +) + +type Config struct { + // EntityID identifies Sentry to the IdP, conventionally Sentry's own + // metadata URL. + EntityID string + // ACSURL is where the IdP redirects the browser back to with the + // assertion (the "assertion consumer service" endpoint). + ACSURL string + // IDPMetadata is the IdP's metadata XML, fetched out-of-band (IdP + // admin provides a URL or a file) and parsed by the caller via + // samltypes/crewjam's metadata parsing -- kept out of this package's + // constructor so it isn't doing its own network fetch of + // admin-supplied, potentially untrusted URLs. + IDPMetadata *saml.EntityDescriptor + // Certificate/Key sign outgoing AuthnRequests and are required by + // crewjam/saml's ServiceProvider even when the IdP doesn't mandate + // signed requests. If nil, New generates a self-signed keypair -- + // fine for development, but a real deployment should supply a + // certificate its IdP is configured to trust for encrypted + // assertions, not rely on the generated one long-term. + Certificate *tls.Certificate +} + +type ServiceProvider struct { + sp saml.ServiceProvider +} + +func New(cfg Config) (*ServiceProvider, error) { + if cfg.EntityID == "" || cfg.ACSURL == "" { + return nil, fmt.Errorf("saml: EntityID and ACSURL are required") + } + if cfg.IDPMetadata == nil { + return nil, fmt.Errorf("saml: IDPMetadata is required") + } + + cert := cfg.Certificate + if cert == nil { + generated, err := selfSignedCert() + if err != nil { + return nil, fmt.Errorf("saml: generating a self-signed certificate: %w", err) + } + cert = generated + } + + acsURL, err := url.Parse(cfg.ACSURL) + if err != nil { + return nil, fmt.Errorf("saml: parsing ACSURL: %w", err) + } + entityID, err := url.Parse(cfg.EntityID) + if err != nil { + return nil, fmt.Errorf("saml: parsing EntityID: %w", err) + } + + return &ServiceProvider{ + sp: saml.ServiceProvider{ + Key: cert.PrivateKey.(*rsa.PrivateKey), + Certificate: parseLeaf(cert), + MetadataURL: *entityID, + AcsURL: *acsURL, + IDPMetadata: cfg.IDPMetadata, + }, + }, nil +} + +// LoginURL builds the redirect that starts SP-initiated SSO. relayState +// round-trips through the IdP and comes back with the response -- +// typically where to send the browser after login completes, validated +// by the caller the same way OIDC's state parameter is (this package +// doesn't store it). +func (s *ServiceProvider) LoginURL(relayState string) (string, error) { + req, err := s.sp.MakeAuthenticationRequest(s.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding) + if err != nil { + return "", fmt.Errorf("saml: building authentication request: %w", err) + } + redirectURL, err := req.Redirect(relayState, &s.sp) + if err != nil { + return "", fmt.Errorf("saml: building redirect URL: %w", err) + } + return redirectURL.String(), nil +} + +// Claims is the subset of an assertion Sentry uses -- same "extend +// deliberately" reasoning as oidc.Claims. +type Claims struct { + NameID string + Email string +} + +// ParseResponse validates an incoming SAML response (signature, issuer, +// audience, timing) and extracts the fields Sentry cares about. This is +// the step that actually establishes trust -- crewjam/saml's +// ParseResponse does the XML signature verification, not this package. +func (s *ServiceProvider) ParseResponse(r *http.Request, possibleRequestIDs []string) (*Claims, error) { + assertion, err := s.sp.ParseResponse(r, possibleRequestIDs) + if err != nil { + return nil, fmt.Errorf("saml: parsing/validating response: %w", err) + } + + claims := &Claims{} + if assertion.Subject != nil && assertion.Subject.NameID != nil { + claims.NameID = assertion.Subject.NameID.Value + } + for _, stmt := range assertion.AttributeStatements { + for _, attr := range stmt.Attributes { + if attr.Name == "email" || attr.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" { + if len(attr.Values) > 0 { + claims.Email = attr.Values[0].Value + } + } + } + } + return claims, nil +} + +func parseLeaf(cert *tls.Certificate) *x509.Certificate { + if len(cert.Certificate) == 0 { + return nil + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil + } + return leaf +} + +// selfSignedCert generates a throwaway RSA keypair + certificate for +// development use, per Config.Certificate's doc comment. +func selfSignedCert() (*tls.Certificate, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "sentry-saml-sp-dev"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour * 365), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return nil, err + } + return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil +} diff --git a/enterprise/internal/saml/saml_test.go b/enterprise/internal/saml/saml_test.go new file mode 100644 index 0000000..a336a20 --- /dev/null +++ b/enterprise/internal/saml/saml_test.go @@ -0,0 +1,58 @@ +package saml + +import ( + "testing" + + "github.com/crewjam/saml" +) + +func fakeIDPMetadata() *saml.EntityDescriptor { + return &saml.EntityDescriptor{ + EntityID: "https://idp.example.com/metadata", + IDPSSODescriptors: []saml.IDPSSODescriptor{ + { + SingleSignOnServices: []saml.Endpoint{ + {Binding: saml.HTTPRedirectBinding, Location: "https://idp.example.com/sso"}, + }, + }, + }, + } +} + +func TestNewRejectsMissingConfig(t *testing.T) { + _, err := New(Config{}) + if err == nil { + t.Fatalf("expected an error for an empty config") + } +} + +func TestNewRejectsMissingIDPMetadata(t *testing.T) { + _, err := New(Config{EntityID: "https://sentry.example.com/saml/metadata", ACSURL: "https://sentry.example.com/saml/acs"}) + if err == nil { + t.Fatalf("expected an error when IDPMetadata is missing") + } +} + +// TestLoginURLBuildsAgainstRealIDPMetadata exercises the actual +// crewjam/saml AuthnRequest-building and redirect-encoding path (deflate +// + base64 + query-string construction) against IdP metadata shaped like +// what a real IdP publishes, confirming the wiring produces a usable +// redirect rather than just "the code compiles." +func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) { + sp, err := New(Config{ + EntityID: "https://sentry.example.com/saml/metadata", + ACSURL: "https://sentry.example.com/saml/acs", + IDPMetadata: fakeIDPMetadata(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + redirectURL, err := sp.LoginURL("relay-state-123") + if err != nil { + t.Fatalf("LoginURL: %v", err) + } + if redirectURL == "" { + t.Fatalf("expected a non-empty redirect URL") + } +} diff --git a/enterprise/internal/session/session.go b/enterprise/internal/session/session.go new file mode 100644 index 0000000..6057e23 --- /dev/null +++ b/enterprise/internal/session/session.go @@ -0,0 +1,133 @@ +// Package session issues and validates the signed tokens enterprise-auth +// hands back to /api's authz.HTTPAuthorizer -- both human sessions +// (issued after a successful OIDC/SAML login) and the long-lived +// RoleService credential /alerting's queryclient presents as a Bearer +// token. HS256/JWT rather than a bespoke format: boring, well-understood, +// and go-jose is already a dependency via oidc. +// +// One shared signing key (ENTERPRISE_SESSION_SIGNING_KEY) issues and +// validates both kinds of token -- there is deliberately no separate key +// per token type, since the Role claim (not the key used) is what +// authz.Role.Satisfies enforces downstream. +package session + +import ( + "errors" + "fmt" + "time" + + josev4 "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// Claims mirrors api/internal/authz.Identity's fields (TenantID, UserID, +// Role as a string) plus the standard registered JWT claims. Role is +// deliberately a plain string, not enterprise's own type, since its only +// consumer -- authz.Role -- is defined in core and this package must not +// import it (core must not import enterprise/, but the reverse also +// stays a network boundary here: this package has no reason to depend on +// api's Go types either). +type Claims struct { + TenantID string `json:"tenant_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Role string `json:"role"` + jwt.Claims +} + +const ( + // HumanSessionTTL matches a typical browser-session lifetime; re-auth + // happens via a fresh OIDC/SAML round trip, not silent refresh (no + // refresh-token flow is built yet -- named future work). + HumanSessionTTL = 12 * time.Hour + // ServiceTokenTTL is long-lived by design: /alerting runs as a + // continuously-deployed workload with no interactive re-auth path. + // Rotation is by redeploying alerting with a freshly issued token, + // not automatic refresh. + ServiceTokenTTL = 24 * 365 * time.Hour + // MinSigningKeyBytes: HS256 wants a key at least as long as its + // output (32 bytes/256 bits) to not weaken the MAC. + MinSigningKeyBytes = 32 +) + +// ErrInvalidToken covers every validation failure (bad signature, +// malformed token, expired) -- deliberately not distinguished further so +// callers can't be tempted to treat "expired" as a softer case than +// "forged"; both mean "do not trust this caller." +var ErrInvalidToken = errors.New("session: invalid or expired token") + +type Manager struct { + signer josev4.Signer + key []byte +} + +func NewManager(signingKey []byte) (*Manager, error) { + if len(signingKey) < MinSigningKeyBytes { + return nil, fmt.Errorf("session: signing key must be at least %d bytes, got %d", MinSigningKeyBytes, len(signingKey)) + } + signer, err := josev4.NewSigner( + josev4.SigningKey{Algorithm: josev4.HS256, Key: signingKey}, + (&josev4.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return nil, fmt.Errorf("session: creating signer: %w", err) + } + return &Manager{signer: signer, key: signingKey}, nil +} + +// IssueUserSession issues a human session token for a resolved +// tenant/user/role -- called only after a successful OIDC/SAML callback +// validates the caller's identity; this function trusts its inputs +// completely, same "one production call site, verified by review" shape +// as tenant.TrustFromValidatedSession. +func (m *Manager) IssueUserSession(tenantID, userID, role string) (string, error) { + now := time.Now() + claims := Claims{ + TenantID: tenantID, + UserID: userID, + Role: role, + Claims: jwt.Claims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(HumanSessionTTL)), + }, + } + return jwt.Signed(m.signer).Claims(claims).Serialize() +} + +// IssueServiceToken issues a RoleService credential for a named machine +// caller (subject identifies which one, e.g. "alerting", for audit/ +// revocation bookkeeping). TenantID/UserID are deliberately left empty: +// per /docs/phase-4-isolation-design.md's alerting↔api gap, the caller's +// tenant is resolved server-side per-request from the resource being +// acted on (alert_rules.tenant_id), never taken from the token or the +// request body -- a service token proves "this caller is alerting," not +// "this caller may act as tenant X." +func (m *Manager) IssueServiceToken(subject string) (string, error) { + now := time.Now() + claims := Claims{ + Role: "service", + Claims: jwt.Claims{ + Subject: subject, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(ServiceTokenTTL)), + }, + } + return jwt.Signed(m.signer).Claims(claims).Serialize() +} + +// Validate verifies signature and expiry and returns the token's claims. +// Every failure mode collapses to ErrInvalidToken -- see its doc comment. +func (m *Manager) Validate(token string) (Claims, error) { + parsed, err := jwt.ParseSigned(token, []josev4.SignatureAlgorithm{josev4.HS256}) + if err != nil { + return Claims{}, ErrInvalidToken + } + var claims Claims + if err := parsed.Claims(m.key, &claims); err != nil { + return Claims{}, ErrInvalidToken + } + if err := claims.Claims.Validate(jwt.Expected{}); err != nil { + return Claims{}, ErrInvalidToken + } + return claims, nil +} diff --git a/enterprise/internal/session/session_test.go b/enterprise/internal/session/session_test.go new file mode 100644 index 0000000..315559c --- /dev/null +++ b/enterprise/internal/session/session_test.go @@ -0,0 +1,118 @@ +package session + +import ( + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4/jwt" +) + +func testKey() []byte { + return []byte("this-is-a-32-byte-test-signing-key!") +} + +func TestNewManagerRejectsShortKey(t *testing.T) { + if _, err := NewManager([]byte("too-short")); err == nil { + t.Fatal("expected an error for a signing key under 32 bytes") + } +} + +func TestIssueAndValidateUserSession(t *testing.T) { + m, err := NewManager(testKey()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + token, err := m.IssueUserSession("acme", "u1", "editor") + if err != nil { + t.Fatalf("IssueUserSession: %v", err) + } + claims, err := m.Validate(token) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if claims.TenantID != "acme" || claims.UserID != "u1" || claims.Role != "editor" { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +func TestIssueAndValidateServiceToken(t *testing.T) { + m, err := NewManager(testKey()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + token, err := m.IssueServiceToken("alerting") + if err != nil { + t.Fatalf("IssueServiceToken: %v", err) + } + claims, err := m.Validate(token) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if claims.Role != "service" || claims.Subject != "alerting" { + t.Fatalf("unexpected claims: %+v", claims) + } + if claims.TenantID != "" || claims.UserID != "" { + t.Fatalf("service token must not carry a tenant/user -- tenant is resolved server-side per request, got %+v", claims) + } +} + +func TestValidateRejectsTamperedToken(t *testing.T) { + m, err := NewManager(testKey()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + token, err := m.IssueUserSession("acme", "u1", "viewer") + if err != nil { + t.Fatalf("IssueUserSession: %v", err) + } + // Flip a character in the payload segment to simulate tampering. + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("expected a 3-segment JWT, got %d segments", len(parts)) + } + tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2] + if _, err := m.Validate(tampered); err != ErrInvalidToken { + t.Fatalf("Validate(tampered) error = %v, want ErrInvalidToken", err) + } +} + +func TestValidateRejectsWrongKey(t *testing.T) { + m1, err := NewManager(testKey()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + m2, err := NewManager([]byte("a-completely-different-32-byte-key!")) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + token, err := m1.IssueUserSession("acme", "u1", "viewer") + if err != nil { + t.Fatalf("IssueUserSession: %v", err) + } + if _, err := m2.Validate(token); err != ErrInvalidToken { + t.Fatalf("Validate with wrong key error = %v, want ErrInvalidToken", err) + } +} + +func TestValidateRejectsExpiredToken(t *testing.T) { + m, err := NewManager(testKey()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + now := time.Now() + claims := Claims{ + TenantID: "acme", UserID: "u1", Role: "viewer", + Claims: jwt.Claims{ + IssuedAt: jwt.NewNumericDate(now.Add(-2 * time.Hour)), + Expiry: jwt.NewNumericDate(now.Add(-1 * time.Hour)), + }, + } + token, err := jwt.Signed(m.signer).Claims(claims).Serialize() + if err != nil { + t.Fatalf("building an already-expired token: %v", err) + } + if _, err := m.Validate(token); err != ErrInvalidToken { + t.Fatalf("Validate(expired) error = %v, want ErrInvalidToken", err) + } +} diff --git a/enterprise/internal/tenant/tenant.go b/enterprise/internal/tenant/tenant.go new file mode 100644 index 0000000..913e60b --- /dev/null +++ b/enterprise/internal/tenant/tenant.go @@ -0,0 +1,67 @@ +// Package tenant is the single source of truth for "which tenant is +// this request for" -- see /docs/phase-4-isolation-design.md's "TenantID: +// an honest framing, not an oversold one" section before changing +// anything here. +// +// The unexported field on ID and the single production constructor make +// *accidental* misuse cheap to audit (grep for call sites) -- they do +// not make misuse impossible by the Go compiler alone. The real +// invariant: TrustFromValidatedSession has exactly one production call +// site, verified by CI (hack/check-tenant-boundary.sh) and code review +// at every change to this package. The database/index grant layer in +// internal/chrunner and internal/searchclient is the actual backstop. +// Do not add a second exported or reflection-accessible construction +// path (e.g. an UnmarshalJSON method) without re-reading that design +// doc section first -- it exists specifically because a future +// "convenience" constructor is the most realistic way this boundary +// gets quietly reopened. +package tenant + +import "context" + +// ID identifies a tenant. The zero value is not a valid ID -- always +// check the bool from FromContext. +type ID struct { + value string +} + +func (id ID) String() string { + return id.value +} + +// contextKey is unexported specifically so nothing outside this package +// can set or shadow the context value via context.WithValue with a +// string or exported key -- see the design doc's "context key collision" +// gap. +type contextKey struct{} + +// FromContext is the only read path for a request's tenant. +func FromContext(ctx context.Context) (ID, bool) { + id, ok := ctx.Value(contextKey{}).(ID) + return id, ok +} + +// WithContext attaches id to ctx. Called once, by auth middleware, right +// after TrustFromValidatedSession. +func WithContext(ctx context.Context, id ID) context.Context { + return context.WithValue(ctx, contextKey{}, id) +} + +// TrustFromValidatedSession is the only construction path from a raw +// string. In production code, DO NOT CALL OUTSIDE auth middleware +// (internal/session) -- enforced by hack/check-tenant-boundary.sh, which +// greps *.go files (excluding _test.go) for call sites outside an +// allowlist. Other packages' tests calling this directly is expected and +// fine: test code isn't attacker-controlled the way a network-facing +// handler is, so there's no separate "test constructor" here -- an +// earlier draft of this design proposed one living in a _test.go file, +// on the mistaken assumption that would make it importable by other +// packages' tests as a compiler-enforced guarantee. It doesn't: Go never +// compiles _test.go files into what other packages (or other packages' +// tests) import, so a same-package-only test constructor would have been +// unreachable from anywhere outside this package, including its +// intended callers. This function, called directly, is simpler and +// actually works. +func TrustFromValidatedSession(raw string) ID { + return ID{value: raw} +} diff --git a/enterprise/internal/tenant/tenant_test.go b/enterprise/internal/tenant/tenant_test.go new file mode 100644 index 0000000..34d4ccd --- /dev/null +++ b/enterprise/internal/tenant/tenant_test.go @@ -0,0 +1,37 @@ +package tenant + +import ( + "context" + "testing" +) + +func TestFromContextRoundTrip(t *testing.T) { + id := TrustFromValidatedSession("acme-corp") + ctx := WithContext(context.Background(), id) + + got, ok := FromContext(ctx) + if !ok { + t.Fatalf("expected FromContext to find a tenant") + } + if got.String() != "acme-corp" { + t.Fatalf("got %q, want %q", got.String(), "acme-corp") + } +} + +func TestFromContextMissing(t *testing.T) { + _, ok := FromContext(context.Background()) + if ok { + t.Fatalf("expected no tenant in a bare context") + } +} + +func TestFromContextDoesNotMatchUnrelatedStringKey(t *testing.T) { + // The unexported contextKey type is what closes the "collision" gap + // the design doc calls out -- a context.WithValue using a plain + // string key must not be found by FromContext. + ctx := context.WithValue(context.Background(), "tenant_id", "spoofed") //nolint:staticcheck + _, ok := FromContext(ctx) + if ok { + t.Fatalf("FromContext must not find a value set under an unrelated key type") + } +} diff --git a/hack/check-tenant-boundary.sh b/hack/check-tenant-boundary.sh new file mode 100755 index 0000000..3b35101 --- /dev/null +++ b/hack/check-tenant-boundary.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Enforces the two boundary properties /docs/phase-4-isolation-design.md +# and /docs/phase-4-rbac-design.md describe as "grep-and-review-enforced, +# not compiler-enforced" -- this script IS that enforcement. Run in CI on +# every change; both checks exit non-zero (and print the offending lines) +# on a violation. +# +# 1. No AGPL-core Go code imports enterprise/ -- core must stay +# genuinely single-tenant with zero multi-tenant mechanism present, +# per the licensing-boundary decision confirmed for Phase 4. +# 2. tenant.TrustFromValidatedSession is called, in non-test production +# code, only from the auth-middleware allowlist below -- everywhere +# else is either a mistake or a new call site that needs the same +# scrutiny the original one got. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +fail=0 + +echo "Checking: no core Go package imports enterprise/..." +# Core = every top-level Go module except enterprise/ and hack/ (hack/ +# tooling isn't shipped, and load-test/fixture scripts have no reason to +# import enterprise/ either, but they're not part of the licensing +# boundary claim, so they're excluded rather than asserted about). +core_hits="$(grep -rn '"github.com/sentry/sentry/enterprise' \ + --include='*.go' \ + agent ingest storage api web cli alerting 2>/dev/null || true)" +if [[ -n "$core_hits" ]]; then + echo "FAIL: core Go code imports enterprise/ -- this must never happen:" + echo "$core_hits" + fail=1 +else + echo "OK: no core package imports enterprise/" +fi + +echo "Checking: tenant.TrustFromValidatedSession call sites..." +# Allowlist: files permitted to call the trust constructor in non-test +# code. Update this list deliberately, one line per new legitimate +# caller, as auth middleware (task 5) and tenant provisioning (task 4+) +# land -- an addition here should get the same review a change to +# enterprise/internal/tenant/tenant.go itself would. +allowlist=( + "enterprise/internal/tenant/tenant.go" # the definition itself +) + +hits="$(grep -rn 'tenant\.TrustFromValidatedSession(' \ + --include='*.go' \ + enterprise 2>/dev/null | grep -v '_test\.go:' || true)" + +violations="" +while IFS= read -r line; do + [[ -z "$line" ]] && continue + file="${line%%:*}" + allowed=0 + for a in "${allowlist[@]}"; do + [[ "$file" == "$a" ]] && allowed=1 && break + done + if [[ "$allowed" -eq 0 ]]; then + violations+="$line"$'\n' + fi +done <<< "$hits" + +if [[ -n "$violations" ]]; then + echo "FAIL: tenant.TrustFromValidatedSession called outside the allowlist:" + echo "$violations" + echo "If this is a legitimate new caller (e.g. new auth middleware), add it to" + echo "the allowlist in this script deliberately -- don't silence this check." + fail=1 +else + echo "OK: TrustFromValidatedSession has no unexpected call sites" +fi + +if [[ "$fail" -ne 0 ]]; then + exit 1 +fi +echo "check-tenant-boundary: all checks passed" diff --git a/metadata/README.md b/metadata/README.md index 773dbe3..a8954c9 100644 --- a/metadata/README.md +++ b/metadata/README.md @@ -11,11 +11,14 @@ doesn't provide. ## Schema -Six tables across two features, one shared database (`sentry_metadata`): +Seven tables across three features, one shared database (`sentry_metadata`): - `dashboards`, `dashboard_panels` — owned by `/api` (`api/internal/dashboards`) - `notification_targets`, `alert_rules`, `alert_state`, `delivery_log` — owned by `/alerting` +- `audit_log` — owned by `enterprise/internal/audit` (Phase 4). Unlike + every other table here, this one is **not** written through the shared + `sentry` role/pool — see "The `audit_writer` role" below. "Owned" here is a documentation convention, not a technical boundary — both services connect to the same Postgres instance/database, each with @@ -24,6 +27,43 @@ shared across service `internal/` trees for this, matching the existing repo convention that only `/proto` is shared code (and even that isn't shared logic, just generated bindings). +## The `audit_writer` role: a second, more restricted credential + +`audit_log` is append-only by design (see +`/docs/phase-4-isolation-design.md`'s audit-logging section) — a +compliance requirement, not just a convention, so it's backed by two +independent defenses, both verified against a live Postgres, not just +written: + +1. A dedicated `audit_writer` Postgres role (`migrations/0012`-`0014`) + with **only** `INSERT`/`SELECT` grants on `audit_log` — no + `UPDATE`/`DELETE`/`TRUNCATE`, ever. `enterprise/internal/audit.Store` + connects using this role's credentials via its **own** `pgxpool.Pool`, + never the shared `sentry` pool `api`/`alerting`'s other stores use — + reusing the shared pool for audit writes would give audit_log's + application-level credential the same `UPDATE`/`DELETE` grants every + other metadata table has, silently defeating the whole point. +2. A `BEFORE UPDATE OR DELETE` trigger (`migrations/0015`-`0016`) that + rejects the operation for **any** role, including the table owner + (`sentry`) — confirmed live: even `sentry` needs to explicitly + `ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable` (a + privileged, distinct-from-normal-access operation) before it can + modify a row. This is redundant defense-in-depth independent of the + grant, protecting against a future migration accidentally re-granting + `UPDATE` to `audit_writer`. + +`AUDIT_WRITER_PASSWORD` (default `audit-writer-dev-only`, matching every +other dev-only credential in this repo) sets the role's password at +creation time via `psql -v audit_writer_password=...` substitution in +`migrate.sh` — **not** hardcoded in the migration SQL file itself. One +real gotcha found while building this: psql's `:'var'` substitution does +**not** apply inside a `DO $$ ... $$` dollar-quoted block (by design, so +client-side substitution can't corrupt a function/procedure body) — the +role-creation migration is a plain `CREATE ROLE`, not wrapped in an +`IF NOT EXISTS` check, relying on `schema_migrations` tracking for +idempotency instead (the same pattern Phase 1's non-idempotent +`ALTER TABLE ... ADD COLUMN` migration in `/storage` already used). + ## Migration tooling: mirrors `/storage/migrate.sh`, not a framework Same reasoning as `/storage/README.md`: pulling in `golang-migrate` for @@ -52,6 +92,7 @@ Environment variables `migrate.sh` reads (all optional except | `POSTGRES_USER` | `sentry` | | `POSTGRES_PASSWORD` | (empty — must be set) | | `POSTGRES_DATABASE` | `sentry_metadata` | +| `AUDIT_WRITER_PASSWORD` | `audit-writer-dev-only` | The database itself isn't created by `migrate.sh` — the `postgres:16-alpine` image auto-creates `POSTGRES_DB` on first startup, unlike ClickHouse where diff --git a/metadata/migrate.sh b/metadata/migrate.sh index 493e30e..93b8d09 100644 --- a/metadata/migrate.sh +++ b/metadata/migrate.sh @@ -11,6 +11,13 @@ POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_USER="${POSTGRES_USER:-sentry}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" POSTGRES_DATABASE="${POSTGRES_DATABASE:-sentry_metadata}" +# Password for the restricted audit-log-writer Postgres role (Phase 4 +# task 4, see /docs/phase-4-isolation-design.md's audit logging +# section) -- a second, narrower-granted role, not the shared +# POSTGRES_PASSWORD above. Passed to psql via -v so the migration SQL +# file can reference it as :'audit_writer_password' without ever +# hardcoding a credential in a file checked into git. +AUDIT_WRITER_PASSWORD="${AUDIT_WRITER_PASSWORD:-audit-writer-dev-only}" export PGPASSWORD="$POSTGRES_PASSWORD" @@ -18,7 +25,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MIGRATIONS_DIR="${SCRIPT_DIR}/migrations" psql_exec() { - psql -v ON_ERROR_STOP=1 -X -q -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DATABASE" "$@" + psql -v ON_ERROR_STOP=1 -X -q -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DATABASE" \ + -v audit_writer_password="$AUDIT_WRITER_PASSWORD" "$@" } echo "Ensuring schema_migrations table exists..." diff --git a/metadata/migrations/0010_create_audit_log.sql b/metadata/migrations/0010_create_audit_log.sql new file mode 100644 index 0000000..48473eb --- /dev/null +++ b/metadata/migrations/0010_create_audit_log.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS audit_log +( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT NOT NULL, + user_id UUID, + source TEXT NOT NULL CHECK (source IN ('api', 'web', 'cli', 'alerting')), + event_type TEXT NOT NULL CHECK (event_type IN ('query', 'role_change', 'grant_change', 'sso_config_change', 'secret_reveal')), + query_text TEXT, + row_count INT, + duration_ms INT, + status TEXT NOT NULL CHECK (status IN ('success', 'error')), + error_message TEXT, + detail JSONB NOT NULL DEFAULT '{}', + prev_hash TEXT, + row_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +) diff --git a/metadata/migrations/0011_add_audit_log_tenant_created_at_index.sql b/metadata/migrations/0011_add_audit_log_tenant_created_at_index.sql new file mode 100644 index 0000000..e12f223 --- /dev/null +++ b/metadata/migrations/0011_add_audit_log_tenant_created_at_index.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS audit_log_tenant_created_at_idx ON audit_log (tenant_id, created_at DESC) diff --git a/metadata/migrations/0012_create_audit_writer_role.sql b/metadata/migrations/0012_create_audit_writer_role.sql new file mode 100644 index 0000000..9ba3faa --- /dev/null +++ b/metadata/migrations/0012_create_audit_writer_role.sql @@ -0,0 +1 @@ +CREATE ROLE audit_writer LOGIN PASSWORD :'audit_writer_password' diff --git a/metadata/migrations/0013_grant_audit_writer_insert_select.sql b/metadata/migrations/0013_grant_audit_writer_insert_select.sql new file mode 100644 index 0000000..f3c9234 --- /dev/null +++ b/metadata/migrations/0013_grant_audit_writer_insert_select.sql @@ -0,0 +1 @@ +GRANT INSERT, SELECT ON audit_log TO audit_writer diff --git a/metadata/migrations/0014_grant_audit_writer_sequence_usage.sql b/metadata/migrations/0014_grant_audit_writer_sequence_usage.sql new file mode 100644 index 0000000..ce60d78 --- /dev/null +++ b/metadata/migrations/0014_grant_audit_writer_sequence_usage.sql @@ -0,0 +1 @@ +GRANT USAGE ON SEQUENCE audit_log_id_seq TO audit_writer diff --git a/metadata/migrations/0015_create_audit_log_immutability_trigger.sql b/metadata/migrations/0015_create_audit_log_immutability_trigger.sql new file mode 100644 index 0000000..3ecf3b9 --- /dev/null +++ b/metadata/migrations/0015_create_audit_log_immutability_trigger.sql @@ -0,0 +1,5 @@ +CREATE OR REPLACE FUNCTION audit_log_deny_update_delete() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'audit_log is append-only: % is not permitted', TG_OP; +END +$$ LANGUAGE plpgsql diff --git a/metadata/migrations/0016_attach_audit_log_immutability_trigger.sql b/metadata/migrations/0016_attach_audit_log_immutability_trigger.sql new file mode 100644 index 0000000..e4437f9 --- /dev/null +++ b/metadata/migrations/0016_attach_audit_log_immutability_trigger.sql @@ -0,0 +1,3 @@ +CREATE TRIGGER audit_log_immutable + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_deny_update_delete() diff --git a/metadata/migrations/0017_create_users.sql b/metadata/migrations/0017_create_users.sql new file mode 100644 index 0000000..75b579a --- /dev/null +++ b/metadata/migrations/0017_create_users.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS users +( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + -- Set on first successful SSO login (OIDC "sub" or SAML NameID) -- + -- nullable because a user row can exist before their first login in + -- principle (e.g. pre-provisioned by an Admin), though Phase 4's + -- baseline flow always creates the row and the SSO subject together. + sso_subject TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +) diff --git a/metadata/migrations/0018_create_tenants.sql b/metadata/migrations/0018_create_tenants.sql new file mode 100644 index 0000000..a09abf7 --- /dev/null +++ b/metadata/migrations/0018_create_tenants.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS tenants +( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + -- Provisioning state machine from /docs/phase-4-isolation-design.md: + -- every tenant-resolution path must refuse to serve a tenant not in + -- 'active' state, checked server-side against this column. + status TEXT NOT NULL DEFAULT 'provisioning' + CHECK (status IN ('provisioning', 'active', 'suspended', 'deprovisioning')), + clickhouse_database_name TEXT, + tantivy_index_path TEXT, + -- Nullable until the first Owner exists -- a tenant can be created + -- (provisioning) before any user has logged in to claim ownership. + owner_user_id UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +) diff --git a/metadata/migrations/0019_seed_default_tenant.sql b/metadata/migrations/0019_seed_default_tenant.sql new file mode 100644 index 0000000..dc53ec9 --- /dev/null +++ b/metadata/migrations/0019_seed_default_tenant.sql @@ -0,0 +1,8 @@ +-- Every Phase 0-3 row (dashboards, alert_rules, notification_targets -- +-- see their tenant_id DEFAULT 'default' columns) belongs to this tenant. +-- Marked 'active' immediately: this data already exists and is already +-- being served, unlike a genuinely new tenant that must pass through +-- provisioning first. +INSERT INTO tenants (id, display_name, status) +VALUES ('default', 'Default', 'active') +ON CONFLICT (id) DO NOTHING diff --git a/metadata/migrations/0020_create_tenant_memberships.sql b/metadata/migrations/0020_create_tenant_memberships.sql new file mode 100644 index 0000000..2875864 --- /dev/null +++ b/metadata/migrations/0020_create_tenant_memberships.sql @@ -0,0 +1,14 @@ +-- A user's role is per-tenant, not global -- see +-- /docs/phase-4-rbac-design.md's role matrix (Viewer/Editor/Admin/Owner). +-- One row per (tenant, user); a user with no row for a tenant has no +-- access to it at all (default-deny, not default-viewer). +CREATE TABLE IF NOT EXISTS tenant_memberships +( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, user_id) +) diff --git a/metadata/migrations/0021_add_tenant_memberships_user_id_index.sql b/metadata/migrations/0021_add_tenant_memberships_user_id_index.sql new file mode 100644 index 0000000..81a8617 --- /dev/null +++ b/metadata/migrations/0021_add_tenant_memberships_user_id_index.sql @@ -0,0 +1,4 @@ +-- Supports "which tenants does this user belong to" (session/authorize +-- lookups), the reverse direction from the UNIQUE(tenant_id, user_id) +-- constraint's own index. +CREATE INDEX IF NOT EXISTS idx_tenant_memberships_user_id ON tenant_memberships (user_id) diff --git a/metadata/migrations/0022_add_alert_state_tenant_id.sql b/metadata/migrations/0022_add_alert_state_tenant_id.sql new file mode 100644 index 0000000..7f303a7 --- /dev/null +++ b/metadata/migrations/0022_add_alert_state_tenant_id.sql @@ -0,0 +1,18 @@ +-- Correction to a Phase 3 gap found during Phase 4 planning: alert_state +-- never got a tenant_id column, unlike alert_rules/dashboards/ +-- notification_targets. Backfilled via a join through alert_rules.id +-- (its owning rule's tenant), not blindly defaulted, even though in +-- practice every pre-Phase-4 row's rule already belongs to 'default'. +-- Bundled as one migration (add nullable -> backfill -> enforce NOT +-- NULL) since it's one logical schema change, same shape as +-- /storage/migrations/0002_add_record_id.sql bundling an ADD COLUMN +-- with its index in one file. +ALTER TABLE alert_state ADD COLUMN IF NOT EXISTS tenant_id TEXT; + +UPDATE alert_state +SET tenant_id = alert_rules.tenant_id +FROM alert_rules +WHERE alert_state.rule_id = alert_rules.id + AND alert_state.tenant_id IS NULL; + +ALTER TABLE alert_state ALTER COLUMN tenant_id SET NOT NULL diff --git a/metadata/migrations/0023_add_delivery_log_tenant_id.sql b/metadata/migrations/0023_add_delivery_log_tenant_id.sql new file mode 100644 index 0000000..f29f9d1 --- /dev/null +++ b/metadata/migrations/0023_add_delivery_log_tenant_id.sql @@ -0,0 +1,10 @@ +-- Same gap and same fix as 0022, for delivery_log. +ALTER TABLE delivery_log ADD COLUMN IF NOT EXISTS tenant_id TEXT; + +UPDATE delivery_log +SET tenant_id = alert_rules.tenant_id +FROM alert_rules +WHERE delivery_log.rule_id = alert_rules.id + AND delivery_log.tenant_id IS NULL; + +ALTER TABLE delivery_log ALTER COLUMN tenant_id SET NOT NULL diff --git a/metadata/migrations/0024_create_dashboard_permissions.sql b/metadata/migrations/0024_create_dashboard_permissions.sql new file mode 100644 index 0000000..639a2b6 --- /dev/null +++ b/metadata/migrations/0024_create_dashboard_permissions.sql @@ -0,0 +1,14 @@ +-- Additive-only per-resource grant: lets a specific user exceed their +-- tenant-baseline role on one dashboard (e.g. an Editor granted Admin on +-- a dashboard they don't own). No deny-overrides -- named non-goal in +-- /docs/phase-4-rbac-design.md and CLAUDE.md's Phase 4 exit criteria. +CREATE TABLE IF NOT EXISTS dashboard_permissions +( + id UUID PRIMARY KEY, + dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin')), + granted_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (dashboard_id, user_id) +) diff --git a/metadata/migrations/0025_create_data_sources.sql b/metadata/migrations/0025_create_data_sources.sql new file mode 100644 index 0000000..14f094c --- /dev/null +++ b/metadata/migrations/0025_create_data_sources.sql @@ -0,0 +1,14 @@ +-- Extension point named in /docs/phase-4-rbac-design.md: one row +-- per-tenant today (each tenant has exactly one ClickHouse database + +-- one Tantivy index), not pretending multiple sources per tenant exist +-- yet -- that's real future work, this table just leaves room for it. +CREATE TABLE IF NOT EXISTS data_sources +( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id), + name TEXT NOT NULL, + clickhouse_database_name TEXT NOT NULL, + tantivy_index_path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, name) +) diff --git a/metadata/migrations/0026_seed_default_tenant_data_source.sql b/metadata/migrations/0026_seed_default_tenant_data_source.sql new file mode 100644 index 0000000..7d21e0b --- /dev/null +++ b/metadata/migrations/0026_seed_default_tenant_data_source.sql @@ -0,0 +1,7 @@ +-- The 'default' tenant's single data source, pointing at the one +-- ClickHouse database ("sentry") and Tantivy index every Phase 0-3 +-- deployment already uses -- see api/internal/config's CLICKHOUSE_DATABASE +-- default and search's index path default. +INSERT INTO data_sources (id, tenant_id, name, clickhouse_database_name, tantivy_index_path) +SELECT '00000000-0000-0000-0000-000000000001', 'default', 'default', 'sentry', '/var/lib/sentry-search' +WHERE NOT EXISTS (SELECT 1 FROM data_sources WHERE tenant_id = 'default') diff --git a/metadata/migrations/0027_add_dashboards_tenant_fk.sql b/metadata/migrations/0027_add_dashboards_tenant_fk.sql new file mode 100644 index 0000000..c8ef5b4 --- /dev/null +++ b/metadata/migrations/0027_add_dashboards_tenant_fk.sql @@ -0,0 +1 @@ +ALTER TABLE dashboards ADD CONSTRAINT fk_dashboards_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) diff --git a/metadata/migrations/0028_add_alert_rules_tenant_fk.sql b/metadata/migrations/0028_add_alert_rules_tenant_fk.sql new file mode 100644 index 0000000..5ec95e9 --- /dev/null +++ b/metadata/migrations/0028_add_alert_rules_tenant_fk.sql @@ -0,0 +1 @@ +ALTER TABLE alert_rules ADD CONSTRAINT fk_alert_rules_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) diff --git a/metadata/migrations/0029_add_notification_targets_tenant_fk.sql b/metadata/migrations/0029_add_notification_targets_tenant_fk.sql new file mode 100644 index 0000000..1d10709 --- /dev/null +++ b/metadata/migrations/0029_add_notification_targets_tenant_fk.sql @@ -0,0 +1 @@ +ALTER TABLE notification_targets ADD CONSTRAINT fk_notification_targets_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) diff --git a/metadata/migrations/0030_add_alert_state_tenant_fk.sql b/metadata/migrations/0030_add_alert_state_tenant_fk.sql new file mode 100644 index 0000000..46814cb --- /dev/null +++ b/metadata/migrations/0030_add_alert_state_tenant_fk.sql @@ -0,0 +1 @@ +ALTER TABLE alert_state ADD CONSTRAINT fk_alert_state_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) diff --git a/metadata/migrations/0031_add_delivery_log_tenant_fk.sql b/metadata/migrations/0031_add_delivery_log_tenant_fk.sql new file mode 100644 index 0000000..67628c7 --- /dev/null +++ b/metadata/migrations/0031_add_delivery_log_tenant_fk.sql @@ -0,0 +1 @@ +ALTER TABLE delivery_log ADD CONSTRAINT fk_delivery_log_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f84ad5f..6a3dadd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,12 @@ export const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'; export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http://localhost:8081'; +// Optional third backend (Phase 4, commercial-license) -- undefined in a +// deployment that hasn't built/deployed enterprise-auth, same "runtime +// capability check" shape /docs/phase-4-rbac-design.md's Web UI boundary +// section describes. getAuthFeatures below treats a missing base URL the +// same as a failed fetch: everything reports disabled, no broken links. +export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL as string | undefined; export type Language = '' | 'sql' | 'spl'; @@ -118,6 +124,31 @@ export function deletePanel(dashboardId: string, panelId: string): Promise return request(`/dashboards/${dashboardId}/panels/${panelId}`, { method: 'DELETE' }); } +export type AuthFeatures = { sso_configured: boolean; oidc_enabled: boolean; saml_enabled: boolean }; + +// getAuthFeatures never throws -- an absent/unreachable enterprise-auth +// is a normal, expected deployment shape (Phase 0-3-style single-tenant, +// no enterprise/ deployed), not an error condition the settings page +// should surface. Deliberately no `credentials: 'include'` here: this +// endpoint needs no session, and CORS_ALLOWED_ORIGIN's dev default of +// "*" can't be combined with credentialed fetches anyway (the browser +// rejects that combination) -- the same reason api.ts's other requests +// don't send credentials yet. Wiring session cookies through is part of +// the deferred login-flow work, alongside tightening CORS_ALLOWED_ORIGIN +// to a real origin, done together as one change so neither breaks the +// other. +export async function getAuthFeatures(): Promise { + const disabled = { sso_configured: false, oidc_enabled: false, saml_enabled: false }; + if (!enterpriseAuthBase) return disabled; + try { + const res = await fetch(`${enterpriseAuthBase}/auth/features`); + if (!res.ok) return disabled; + return await res.json(); + } catch { + return disabled; + } +} + export function exportDashboard(id: string): Promise { return request(`/dashboards/${id}/export`); } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 685660a..02999b3 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -16,6 +16,7 @@ Query Dashboards Alerts + Settings {@render children()} diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte new file mode 100644 index 0000000..fc580a6 --- /dev/null +++ b/web/src/routes/settings/+page.svelte @@ -0,0 +1,66 @@ + + +
+

Settings

+ +
+

Core

+

Single-tenant deployment settings live here. Nothing configurable yet.

+
+ + {#if loading} +

Loading…

+ {:else if features.sso_configured} +
+

Single sign-on

+
    + {#if features.oidc_enabled}
  • OIDC configured
  • {/if} + {#if features.saml_enabled}
  • SAML configured
  • {/if} +
+

+ User/role management isn't built yet -- an Admin or Owner will manage tenant members here once + it is. +

+
+ {:else if enterpriseAuthBase} +
+

Single sign-on

+

enterprise-auth is deployed but no SSO provider is configured yet.

+
+ {/if} + +
+ + diff --git a/web/src/routes/settings/+page.ts b/web/src/routes/settings/+page.ts new file mode 100644 index 0000000..13942fa --- /dev/null +++ b/web/src/routes/settings/+page.ts @@ -0,0 +1,3 @@ +// Same shape as dashboards/+page.ts: no route params, data comes from a +// client-side fetch. +export const prerender = true;