Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment

RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+75 -4
View File
@@ -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
+1 -1
View File
@@ -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()
+5
View File
@@ -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", "*"),
+13 -2
View File
@@ -24,11 +24,19 @@ type errorResponse struct {
type Client struct {
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 {
@@ -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)
}
}
+13 -2
View File
@@ -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
+80
View File
@@ -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)
}
+36
View File
@@ -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)
}
}
}
+65
View File
@@ -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
}
+72
View File
@@ -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)
}
}
+72
View File
@@ -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)))
}
}
+96
View File
@@ -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)
}
}
+7
View File
@@ -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"))
+65 -31
View File
@@ -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
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
}
+206 -21
View File
@@ -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 {
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)
}
}
}
+79 -19
View File
@@ -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"
@@ -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")
}
}
+69 -3
View File
@@ -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)
+134 -1
View File
@@ -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())
}
}
@@ -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.")
}
+123
View File
@@ -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")
}
}
+4 -3
View File
@@ -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
+4 -3
View File
@@ -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
+8 -1
View File
@@ -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
+9 -1
View File
@@ -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
+26 -4
View File
@@ -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
+15 -1
View File
@@ -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 <token>" 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"`
}
+78
View File
@@ -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.
+12
View File
@@ -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"
+85
View File
@@ -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).
@@ -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
+55
View File
@@ -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 -}}
@@ -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
+79
View File
@@ -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
@@ -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
@@ -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 }}
+65
View File
@@ -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
+110
View File
@@ -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
+108
View File
@@ -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"
+71
View File
@@ -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
+81
View File
@@ -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 }}
@@ -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 }}
+14
View File
@@ -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 }}
+43
View File
@@ -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
+180
View File
@@ -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: []
+12
View File
@@ -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"]
+81
View File
@@ -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/<name>/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
```
+71
View File
@@ -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)
}
}
@@ -0,0 +1,27 @@
// Package v1alpha1 contains the Tenant API's Go types -- kubebuilder's
// standard api/<version>/ 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
)
@@ -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{})
}
@@ -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
}
@@ -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/<service>/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)
}
}
@@ -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
+67
View File
@@ -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
)
+192
View File
@@ -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=
@@ -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)
}
@@ -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)
}
}
+35
View File
@@ -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"
+89 -22
View File
@@ -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
+317
View File
@@ -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 03 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 03-appropriate
shortcut that must not carry into tenant-scoped connections. `default`
(or an equivalent broad-access account) is reserved for
migrations/provisioning/ops only, never handed to a request-serving
code path.
**2. `system.*` access is explicitly revoked from every tenant user,
not left at whatever ClickHouse's default template grants.**
`system.query_log` records every query's full text by default, and is
broadly readable unless explicitly revoked — so even with per-database
row isolation working *perfectly*, a tenant able to read
`system.query_log` can see other tenants' query text: predicate values,
field names, sometimes literally sensitive data embedded in a `WHERE`
clause. `SHOW DATABASES`/`system.tables` visibility being properly
grant-scoped is version-dependent on `access_management` actually being
engaged for the account, not the default `users.xml`-style setup. This
is not assumed from documentation — it's verified by an adversarial
integration test (Phase 4 task 8) that, as a tenant-scoped user,
attempts `SELECT * FROM system.query_log`, `SELECT * FROM
system.tables`, `SHOW DATABASES`, and a fully-qualified cross-tenant
`SELECT * FROM <other_tenant_db>.logs`, asserting each is denied or
empty. Provisioning (`enterprise/internal/tenantprovision`) explicitly
revokes/never-grants `system.*` as part of creating a tenant user.
**3. Per-tenant connections are fully separate `driver.Conn`/pool
objects — never one shared pool with session-level `USE tenant_x`.** A
shared-pool-plus-`USE` implementation is a real concurrency bug, not a
theoretical one: a connection recycled between tenants mid-flight can
interleave a `USE` statement for tenant A with a query that actually
executes against tenant B's still-live session state, depending on how
`clickhouse-go/v2` recycles connections under load. This design
mandates N fully separate pools, resolved fresh per request — as a
local variable inside the request-handling goroutine, never cached in a
mutable struct field shared across goroutines — from an
immutable-after-startup `map[TenantID]*ChRunner`. Growing or shrinking
that map (tenant on/offboarding) happens by replacing the map wholesale
(copy-on-write), never by mutating it in place under concurrent readers.
**4. Provisioning is ordered, idempotent, and gated on an explicit
`active` state.** Sequence: `CREATE USER IF NOT EXISTS` with a
zero-privilege base role (not ClickHouse's implicit default profile) →
`GRANT` narrow, tenant-database-scoped access → only *then* mark the
tenant `active` in the `tenants` table (Postgres, `/metadata`). Every
tenant-resolution code path refuses to serve a tenant not in `active`
state, checked against that table server-side — never inferred from "a
connection happened to succeed," which would happily serve traffic
during a half-finished provisioning run. A crashed/retried provisioning
job must not leave a *broader*-than-intended grant live during the
retry window; the ordering above (narrow grant strictly before
`active`) is what prevents that. Deprovisioning must not leave a live
cached connection usable past a revoked grant on some ClickHouse
versions dropping a user doesn't terminate already-open sessions — so
offboarding either explicitly terminates sessions for the tenant's user
or relies on a bounded max lifetime for cached per-tenant connections
(not indefinite reuse).
## Tantivy: index-per-tenant
Same underlying reasoning as ClickHouse, for a sharper reason: Tantivy
has no grant system at all, so "one shared index with a tenant-tagged
field, filtered at query time" would have *zero* structural backing —
purely conventional, exactly the "convention, not structural" failure
mode this whole design exists to avoid. `search`'s current shape (one
`Arc<SearchIndex>` opened once at startup — `search/src/index.rs`,
`search/src/main.rs`) becomes a registry: a `HashMap<TenantID,
Arc<SearchIndex>>` (an LRU if tenant count ever grows large enough that
holding every index open simultaneously is wasteful — not needed for
Phase 4's initial scale), each index a separate directory under a
shared volume, opened or created on demand and resolved only from the
tenant context `enterprise/`'s trusted caller establishes.
`search.proto`'s `SearchRequest` gains a tenant field, populated
exclusively by `enterprise/`'s tenant-scoped `SearchClient`
implementation — never read from anything a remote/external client
supplies.
## The `alerting` ↔ `api` gap
Found by the adversarial review, not present in the first draft — real,
not hypothetical, and it has to be resolved as part of this sign-off
because it shapes Phase 4 task 5's design directly.
**Today's actual behavior**: `alerting`'s evaluator
(`alerting/internal/evaluator/evaluator.go`) claims due rules across
*all* tenants in a single `rulestore.ClaimDueRules` call, then for each
one calls `api`'s `POST /query` via `internal/queryclient/client.go`
with just `{query, language}` — no tenant field, no authentication, at
all, today.
**Why this matters for isolation specifically**: `alerting` is a
machine calling on a schedule, not a human with a session — there is no
session to derive a tenant context from the way a browser request has
one. The tempting, wrong fix is adding a `tenant_id` field to the
`/query` request, populated from the rule's own `TenantID` (which
`rulestore.RuleWithState` already carries after Phase 4's schema
additions). That is *exactly* the client-suppliable tenant identifier
this entire design exists to prevent — `alerting`'s HTTP surface is
unauthenticated today, so anything able to reach it (or spoof a call to
`api` shaped like one) could request any tenant's data by setting that
field.
**The correct fix**, scoped into Phase 4 task 5: a distinct **service
identity** for `alerting` — a signed service token or mTLS client
certificate, not a human session — that `api`/`enterprise/` map to "may
execute the query belonging to rule X," where rule X's tenant is looked
up **server-side** from `alert_rules.tenant_id` (already present after
this phase's schema work), never taken from anything in the request
body. This is a third RBAC category, alongside human roles (Phase 4
task 3), not a variant of session/token handling — it authorizes "run
this one already-persisted, already-tenant-scoped rule," never general
tenant access, so a compromised evaluator can't be used to browse
arbitrary tenant data.
## `TenantID`: an honest framing, not an oversold one
```go
package tenant
type contextKey struct{} // unexported key type -- closes a
// context.WithValue collision gap: an
// exported or string-typed key could be
// shadowed/overwritten by unrelated code
type ID struct{ value string } // unexported field
func (id ID) String() string { return id.value }
func FromContext(ctx context.Context) (ID, bool)
func WithContext(ctx context.Context, id ID) context.Context
// TrustFromValidatedSession is the only production construction path
// from a raw string. DO NOT CALL OUTSIDE auth middleware -- enforced by
// CI grep (hack/check-tenant-boundary.sh), same mechanism as the
// enterprise/-import-boundary check Phase 4 task 3 adds.
func TrustFromValidatedSession(raw string) ID
```
An unexported field with exactly one production constructor makes
*accidental* misuse cheap to audit — grep for call sites — it does not
make misuse impossible by the Go compiler alone, and this document
states that plainly rather than implying otherwise. Concretely:
- It does not stop a *deliberate* second construction path added later
inside the `tenant` package itself — e.g. a future `UnmarshalJSON`
method, added for some unrelated serialization need, which has full
access to the unexported field from within the package and would
happily decode a client-supplied JSON body straight into a trusted
`ID` the moment any handler unmarshals into a struct embedding one.
- **Correction made during implementation**: this design originally
proposed a test-only constructor in `internal/tenant/testing_test.go`,
reasoning that Go's exclusion of `_test.go` files from normal imports
would make it a compiler-enforced constructor reachable by other
packages' tests but not production code. That reasoning was wrong —
Go never compiles `_test.go` files into what *any* other package
imports, including other packages' own tests, so that constructor
would have been unreachable even from its intended callers. There is
no separate test constructor: other packages' tests call
`TrustFromValidatedSession` directly, which is fine, since test code
isn't attacker-controlled the way a network-facing handler is.
- The actual invariant, stated for what it is: *the constructor has
exactly one call site in non-test production code, verified by CI
grep (scanning `*.go`, excluding `*_test.go`) plus code review at
every change to `internal/tenant/`. The database/index grant layer
above is the real backstop. This package makes production violations
visible and rare — it does not make them impossible, and was never
able to restrict test-time construction either, only make it
unnecessary to restrict.*
## Provisioning state machine (summary — full detail in task 6's deploy work)
```
provisioning → active → suspended → deprovisioning → (removed)
```
- `provisioning`: `tenants` row exists, ClickHouse user/grants and
Tantivy index directory are being created. No request is ever served
for a tenant in this state.
- `active`: fully provisioned, narrow grants confirmed applied. Normal
serving state.
- `suspended`: grants revoked (e.g. non-payment, policy violation) but
data retained; no requests served, distinct from `deprovisioning` so
a suspension can be reversed without re-provisioning from scratch.
- `deprovisioning`: offboarding in progress — sessions/connections being
terminated, data export/deletion per retention policy (out of scope
for this document; a compliance/data-retention design, not an
isolation one).
## What this document deliberately does not solve here
- The RBAC role model and its enforcement (Phase 4 task 3, separate
sign-off).
- Audit logging mechanics (Phase 4 task 4).
- Exact SSO protocol flows (Phase 4 task 3).
- Deployment topology for pinning large tenants to dedicated cluster
nodes (Phase 4 task 6) — this document's model is agnostic to where a
tenant's database physically runs, only that it's a distinct
database/user regardless of placement.
- Data retention/deletion semantics during deprovisioning.
## Verification plan for this design specifically
Not just unit tests — this design's real risk is in ClickHouse's actual
runtime grant behavior on the pinned version in `docker-compose.yml`,
which is exactly the kind of thing that looks fine in documentation and
isn't in practice (see item 2 above). Phase 4 task 8's adversarial
suite must include, against the live stack:
- A tenant-scoped ClickHouse user attempting to read another tenant's
database by fully-qualified name in raw SQL.
- The same user attempting `system.query_log`, `system.tables`, `SHOW
DATABASES`.
- A Tantivy search request for one tenant returning zero cross-tenant
results even when another tenant's index contains matching terms.
- A simulated evaluator tick firing mid-provisioning (tenant row exists,
grants not yet confirmed) to confirm it's refused, not silently served
against a partially-provisioned or default-profile connection.
+213
View File
@@ -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.
+233
View File
@@ -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`.
+289
View File
@@ -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** |
+12
View File
@@ -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"]
+148
View File
@@ -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 |
+146
View File
@@ -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
}
+25
View File
@@ -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
)
+70
View File
@@ -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=
+185
View File
@@ -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)
}
+123
View File
@@ -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()
}
+90
View File
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
+100
View File
@@ -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()
}
@@ -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)
}
@@ -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)
}
}
+91
View File
@@ -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
}
+116
View File
@@ -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
}
+75
View File
@@ -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")
}
}
+254
View File
@@ -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()
}
@@ -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)
}
}
+173
View File
@@ -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
}
+58
View File
@@ -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")
}
}
+133
View File
@@ -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
}
+118
View File
@@ -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)
}
}
+67
View File
@@ -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}
}
+37
View File
@@ -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")
}
}
+78
View File
@@ -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"
+42 -1
View File
@@ -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
+9 -1
View File
@@ -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..."
@@ -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()
)
@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS audit_log_tenant_created_at_idx ON audit_log (tenant_id, created_at DESC)
@@ -0,0 +1 @@
CREATE ROLE audit_writer LOGIN PASSWORD :'audit_writer_password'
@@ -0,0 +1 @@
GRANT INSERT, SELECT ON audit_log TO audit_writer
@@ -0,0 +1 @@
GRANT USAGE ON SEQUENCE audit_log_id_seq TO audit_writer
@@ -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
@@ -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()
+13
View File
@@ -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()
)
@@ -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()
)
@@ -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

Some files were not shown because too many files have changed in this diff Show More