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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", "*"),
|
||||
|
||||
@@ -23,12 +23,20 @@ type errorResponse struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
baseURL string
|
||||
serviceToken string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{baseURL: baseURL, http: &http.Client{}}
|
||||
// New builds a client for /api's POST /query. serviceToken, if non-empty,
|
||||
// is sent as a Bearer credential on every request -- api's authz
|
||||
// middleware resolves it (via enterprise-auth) to the RoleService
|
||||
// identity described in /docs/phase-4-isolation-design.md's alerting↔api
|
||||
// gap. An empty serviceToken matches Phase 0-3 behavior (no
|
||||
// enterprise/ deployed, api's authorizer is nil, every request is
|
||||
// allowed).
|
||||
func New(baseURL, serviceToken string) *Client {
|
||||
return &Client{baseURL: baseURL, serviceToken: serviceToken, http: &http.Client{}}
|
||||
}
|
||||
|
||||
// Query runs query (already time-range-injected by the caller, if
|
||||
@@ -51,6 +59,9 @@ func (c *Client) Query(ctx context.Context, query, language string, timeout time
|
||||
return nil, fmt.Errorf("building query request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.serviceToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.serviceToken)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -6,47 +6,80 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/authz"
|
||||
)
|
||||
|
||||
// store is the narrow interface Handler depends on -- *Store (store.go)
|
||||
// is the production implementation; tests use a fake, same pattern as
|
||||
// queryapi's SQLRunner/SearchClient.
|
||||
// queryapi's SQLRunner/SearchClient. Every method except Create/Import
|
||||
// takes a tenantID -- see store.go's doc comment for why.
|
||||
type store interface {
|
||||
CreateDashboard(ctx context.Context, d *Dashboard) error
|
||||
ListDashboards(ctx context.Context) ([]Dashboard, error)
|
||||
GetDashboard(ctx context.Context, id string) (*Dashboard, error)
|
||||
UpdateDashboard(ctx context.Context, d *Dashboard) error
|
||||
DeleteDashboard(ctx context.Context, id string) error
|
||||
AddPanel(ctx context.Context, dashboardID string, p *Panel) error
|
||||
UpdatePanel(ctx context.Context, p *Panel) error
|
||||
DeletePanel(ctx context.Context, dashboardID, panelID string) error
|
||||
ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error)
|
||||
ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error)
|
||||
GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error)
|
||||
UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error
|
||||
DeleteDashboard(ctx context.Context, tenantID, id string) error
|
||||
AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error
|
||||
UpdatePanel(ctx context.Context, tenantID string, p *Panel) error
|
||||
DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error
|
||||
ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
store store
|
||||
logger *slog.Logger
|
||||
store store
|
||||
authorizer authz.Authorizer
|
||||
}
|
||||
|
||||
func NewHandler(logger *slog.Logger, store store) *Handler {
|
||||
return &Handler{logger: logger, store: store}
|
||||
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler {
|
||||
return &Handler{logger: logger, store: store, authorizer: authorizer}
|
||||
}
|
||||
|
||||
// RegisterRoutes wires the RBAC minimum-role bar from
|
||||
// /docs/phase-4-rbac-design.md's matrix. Note what's NOT enforced here
|
||||
// yet: the matrix's "(own/granted)" qualifier for Editor create/edit/
|
||||
// delete requires the dashboard_permissions/ownership lookup that
|
||||
// enterprise/internal/rbacstore hasn't been built yet -- until then,
|
||||
// RoleEditor is necessary but not sufficient per the matrix, and every
|
||||
// Editor can act on every dashboard *within their own tenant* (tenant
|
||||
// scoping itself -- a different, more basic property than the
|
||||
// per-resource "(own/granted)" qualifier -- is enforced, via tenantID
|
||||
// below and store.go's WHERE tenant_id = ... filtering). Tracked as
|
||||
// follow-up, not silently dropped.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /dashboards", h.handleCreate)
|
||||
mux.HandleFunc("GET /dashboards", h.handleList)
|
||||
mux.HandleFunc("POST /dashboards/import", h.handleImport)
|
||||
mux.HandleFunc("GET /dashboards/{id}", h.handleGet)
|
||||
mux.HandleFunc("PUT /dashboards/{id}", h.handleUpdate)
|
||||
mux.HandleFunc("DELETE /dashboards/{id}", h.handleDelete)
|
||||
mux.HandleFunc("GET /dashboards/{id}/export", h.handleExport)
|
||||
mux.HandleFunc("POST /dashboards/{id}/panels", h.handleAddPanel)
|
||||
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", h.handleUpdatePanel)
|
||||
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", h.handleDeletePanel)
|
||||
mux.HandleFunc("POST /dashboards", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleCreate))
|
||||
mux.HandleFunc("GET /dashboards", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList))
|
||||
mux.HandleFunc("POST /dashboards/import", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleImport))
|
||||
mux.HandleFunc("GET /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGet))
|
||||
mux.HandleFunc("PUT /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdate))
|
||||
mux.HandleFunc("DELETE /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDelete))
|
||||
mux.HandleFunc("GET /dashboards/{id}/export", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleExport))
|
||||
mux.HandleFunc("POST /dashboards/{id}/panels", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleAddPanel))
|
||||
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdatePanel))
|
||||
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDeletePanel))
|
||||
}
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi
|
||||
|
||||
// tenantID resolves the tenant to scope a request's store calls to --
|
||||
// from the authenticated identity RequireRole attached to the request
|
||||
// context, never from a client-supplied field (a Dashboard JSON body
|
||||
// can set "tenant_id" to anything; store.go's methods only ever see the
|
||||
// value this function returns, not that field). Falls back to
|
||||
// "default" when no identity is present (nil authorizer -- matches
|
||||
// every other nil-authorizer-is-Phase-0-3-single-tenant default in this
|
||||
// codebase) or when a resolved identity somehow carries no tenant (only
|
||||
// RoleService identities can, per authz.Identity's doc comment, and
|
||||
// RequireRole -- unlike RequireRoleOrService -- never admits RoleService,
|
||||
// so this branch is a defensive fallback, not an expected path).
|
||||
func (h *Handler) tenantID(r *http.Request) string {
|
||||
if id, ok := authz.IdentityFromContext(r.Context()); ok && id.TenantID != "" {
|
||||
return id.TenantID
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var d Dashboard
|
||||
if !decodeJSON(w, r, &d) {
|
||||
@@ -56,6 +89,8 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
// Overrides any client-supplied tenant_id -- see tenantID's doc comment.
|
||||
d.TenantID = h.tenantID(r)
|
||||
if err := h.store.CreateDashboard(r.Context(), &d); err != nil {
|
||||
h.logger.Error("creating dashboard", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "creating dashboard failed")
|
||||
@@ -65,7 +100,7 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.store.ListDashboards(r.Context())
|
||||
list, err := h.store.ListDashboards(r.Context(), h.tenantID(r))
|
||||
if err != nil {
|
||||
h.logger.Error("listing dashboards", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "listing dashboards failed")
|
||||
@@ -75,7 +110,7 @@ func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := h.store.GetDashboard(r.Context(), r.PathValue("id"))
|
||||
d, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
@@ -100,7 +135,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
imported, err := h.store.ImportDashboard(r.Context(), &d)
|
||||
imported, err := h.store.ImportDashboard(r.Context(), h.tenantID(r), &d)
|
||||
if err != nil {
|
||||
h.logger.Error("importing dashboard", "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -119,7 +154,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
d.ID = r.PathValue("id")
|
||||
if err := h.store.UpdateDashboard(r.Context(), &d); err != nil {
|
||||
if err := h.store.UpdateDashboard(r.Context(), h.tenantID(r), &d); err != nil {
|
||||
h.writeStoreErr(w, err, "updating dashboard")
|
||||
return
|
||||
}
|
||||
@@ -127,7 +162,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.DeleteDashboard(r.Context(), r.PathValue("id")); err != nil {
|
||||
if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), r.PathValue("id")); err != nil {
|
||||
h.writeStoreErr(w, err, "deleting dashboard")
|
||||
return
|
||||
}
|
||||
@@ -143,9 +178,8 @@ func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.store.AddPanel(r.Context(), r.PathValue("id"), &p); err != nil {
|
||||
h.logger.Error("adding panel", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "adding panel failed")
|
||||
if err := h.store.AddPanel(r.Context(), h.tenantID(r), r.PathValue("id"), &p); err != nil {
|
||||
h.writeStoreErr(w, err, "adding panel")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
@@ -162,7 +196,7 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
p.ID = r.PathValue("panelId")
|
||||
p.DashboardID = r.PathValue("id")
|
||||
if err := h.store.UpdatePanel(r.Context(), &p); err != nil {
|
||||
if err := h.store.UpdatePanel(r.Context(), h.tenantID(r), &p); err != nil {
|
||||
h.writeStoreErr(w, err, "updating panel")
|
||||
return
|
||||
}
|
||||
@@ -170,7 +204,7 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.DeletePanel(r.Context(), r.PathValue("id"), r.PathValue("panelId")); err != nil {
|
||||
if err := h.store.DeletePanel(r.Context(), h.tenantID(r), r.PathValue("id"), r.PathValue("panelId")); err != nil {
|
||||
h.writeStoreErr(w, err, "deleting panel")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,8 +10,15 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/authz"
|
||||
)
|
||||
|
||||
// fakeStore enforces tenant scoping the same way store.go's real
|
||||
// pgx-backed Store does (a mismatched tenantID behaves exactly like a
|
||||
// missing ID -- ErrNotFound, never a distinguishable "found but wrong
|
||||
// tenant" error) so handler_test.go's cross-tenant tests exercise real
|
||||
// behavior, not a fake that happens to always agree.
|
||||
type fakeStore struct {
|
||||
dashboards map[string]*Dashboard
|
||||
createErr error
|
||||
@@ -31,44 +38,48 @@ func (f *fakeStore) CreateDashboard(_ context.Context, d *Dashboard) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListDashboards(_ context.Context) ([]Dashboard, error) {
|
||||
func (f *fakeStore) ListDashboards(_ context.Context, tenantID string) ([]Dashboard, error) {
|
||||
var out []Dashboard
|
||||
for _, d := range f.dashboards {
|
||||
out = append(out, *d)
|
||||
if d.TenantID == tenantID {
|
||||
out = append(out, *d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetDashboard(_ context.Context, id string) (*Dashboard, error) {
|
||||
func (f *fakeStore) GetDashboard(_ context.Context, tenantID, id string) (*Dashboard, error) {
|
||||
d, ok := f.dashboards[id]
|
||||
if !ok {
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateDashboard(_ context.Context, d *Dashboard) error {
|
||||
func (f *fakeStore) UpdateDashboard(_ context.Context, tenantID string, d *Dashboard) error {
|
||||
existing, ok := f.dashboards[d.ID]
|
||||
if !ok {
|
||||
if !ok || existing.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
panels := existing.Panels
|
||||
*existing = *d
|
||||
existing.TenantID = tenantID
|
||||
existing.Panels = panels
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteDashboard(_ context.Context, id string) error {
|
||||
if _, ok := f.dashboards[id]; !ok {
|
||||
func (f *fakeStore) DeleteDashboard(_ context.Context, tenantID, id string) error {
|
||||
d, ok := f.dashboards[id]
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(f.dashboards, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) error {
|
||||
func (f *fakeStore) AddPanel(_ context.Context, tenantID, dashboardID string, p *Panel) error {
|
||||
d, ok := f.dashboards[dashboardID]
|
||||
if !ok {
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
p.ID = "panel-1"
|
||||
@@ -77,9 +88,9 @@ func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error {
|
||||
func (f *fakeStore) UpdatePanel(_ context.Context, tenantID string, p *Panel) error {
|
||||
d, ok := f.dashboards[p.DashboardID]
|
||||
if !ok {
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
for i := range d.Panels {
|
||||
@@ -91,9 +102,9 @@ func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string) error {
|
||||
func (f *fakeStore) DeletePanel(_ context.Context, tenantID, dashboardID, panelID string) error {
|
||||
d, ok := f.dashboards[dashboardID]
|
||||
if !ok {
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
for i := range d.Panels {
|
||||
@@ -105,18 +116,39 @@ func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string)
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) ImportDashboard(_ context.Context, d *Dashboard) (*Dashboard, error) {
|
||||
func (f *fakeStore) ImportDashboard(_ context.Context, tenantID string, d *Dashboard) (*Dashboard, error) {
|
||||
if f.importErr != nil {
|
||||
return nil, f.importErr
|
||||
}
|
||||
imported := *d
|
||||
imported.ID = "dash-imported"
|
||||
imported.TenantID = tenantID
|
||||
f.dashboards[imported.ID] = &imported
|
||||
return &imported, nil
|
||||
}
|
||||
|
||||
func newTestMux(fs *fakeStore) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs)
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, nil)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
// fakeAuthorizer resolves every request to a fixed identity -- used by
|
||||
// the cross-tenant tests below, which need a real (non-nil) authorizer
|
||||
// so tenantID(r) reads from the resolved identity instead of falling
|
||||
// back to "default" for every request.
|
||||
type fakeAuthorizer struct {
|
||||
identity authz.Identity
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
|
||||
return f.identity, nil
|
||||
}
|
||||
|
||||
func newTestMuxWithTenant(fs *fakeStore, tenantID string) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs,
|
||||
&fakeAuthorizer{identity: authz.Identity{TenantID: tenantID, Role: authz.RoleOwner}})
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
@@ -149,6 +181,25 @@ func TestCreateDashboard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateDashboardIgnoresClientSuppliedTenantID is the regression
|
||||
// test for the tenant-spoofing gap found during Phase 4 task 7 (see
|
||||
// /docs/security/threat-model.md's "application-layer tenant scoping"
|
||||
// section): Dashboard.TenantID has a `json:"tenant_id"` tag, so a
|
||||
// request body can set it to anything. The handler must always
|
||||
// overwrite it from the authenticated identity, never trust the body.
|
||||
func TestCreateDashboardIgnoresClientSuppliedTenantID(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
mux := newTestMuxWithTenant(fs, "acme")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview", "tenant_id": "globex"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.dashboards["dash-1"].TenantID != "acme" {
|
||||
t.Fatalf("stored TenantID = %q, want %q (the authenticated identity's tenant, not the client-supplied value)", fs.dashboards["dash-1"].TenantID, "acme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDashboardRejectsEmptyName(t *testing.T) {
|
||||
mux := newTestMux(newFakeStore())
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": ""}`)
|
||||
@@ -165,9 +216,87 @@ func TestGetDashboardNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossTenantGetIsNotFound is the core adversarial case: a request
|
||||
// authenticated as tenant "globex" must not be able to read a dashboard
|
||||
// that belongs to tenant "acme" -- and the response must be a plain 404
|
||||
// (not a 403, which would confirm the ID exists under a different
|
||||
// tenant).
|
||||
func TestCrossTenantGetIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodGet, "/dashboards/dash-1", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (cross-tenant read must not succeed or leak existence via a different status)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantListDoesNotLeakOtherTenants(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
fs.dashboards["dash-2"] = &Dashboard{ID: "dash-2", TenantID: "globex", Name: "Globex's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodGet, "/dashboards", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got []Dashboard
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "dash-2" {
|
||||
t.Fatalf("expected only globex's own dashboard, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantUpdateIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Hijacked"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if fs.dashboards["dash-1"].Name != "Acme's dashboard" {
|
||||
t.Fatalf("cross-tenant update must not modify the row, got Name = %q", fs.dashboards["dash-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantDeleteIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if _, ok := fs.dashboards["dash-1"]; !ok {
|
||||
t.Fatalf("cross-tenant delete must not remove the row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantAddPanelIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
`{"query": "service=api", "viz_type": "table"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.dashboards["dash-1"].Panels) != 0 {
|
||||
t.Fatalf("cross-tenant AddPanel must not attach a panel to the other tenant's dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPanelRejectsRawSQL(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
@@ -179,7 +308,7 @@ func TestAddPanelRejectsRawSQL(t *testing.T) {
|
||||
|
||||
func TestAddPanelRejectsInvalidVizType(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
@@ -191,7 +320,7 @@ func TestAddPanelRejectsInvalidVizType(t *testing.T) {
|
||||
|
||||
func TestAddPanelSuccess(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
@@ -206,7 +335,7 @@ func TestAddPanelSuccess(t *testing.T) {
|
||||
|
||||
func TestUpdateDashboardChangesTimeRange(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"}
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1",
|
||||
@@ -229,7 +358,7 @@ func TestUpdateDashboardNotFound(t *testing.T) {
|
||||
|
||||
func TestDeleteDashboard(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
||||
@@ -244,7 +373,7 @@ func TestDeleteDashboard(t *testing.T) {
|
||||
func TestExportThenImportRoundTrips(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{
|
||||
ID: "dash-1", Name: "Overview",
|
||||
ID: "dash-1", TenantID: "default", Name: "Overview",
|
||||
Panels: []Panel{{ID: "panel-1", DashboardID: "dash-1", Query: "service=api", VizType: VizTable}},
|
||||
}
|
||||
mux := newTestMux(fs)
|
||||
@@ -267,6 +396,28 @@ func TestExportThenImportRoundTrips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportIgnoresExportedTenantID: an exported dashboard JSON file
|
||||
// carries whatever tenant_id it was exported from -- importing it into
|
||||
// a different tenant's session must assign it to the *importing*
|
||||
// tenant, never silently move it to the tenant named in the file.
|
||||
func TestImportIgnoresExportedTenantID(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/import",
|
||||
`{"name": "Imported", "tenant_id": "acme"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var imported Dashboard
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &imported); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if imported.TenantID != "globex" {
|
||||
t.Fatalf("imported TenantID = %q, want %q (the importing identity's tenant)", imported.TenantID, "globex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.createErr = errors.New("boom")
|
||||
@@ -277,3 +428,37 @@ func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceIdentityCannotAccessDashboards is the other half of the
|
||||
// service-identity boundary (the /query half is
|
||||
// api/internal/queryapi's own tests) -- api/internal/authz's own tests
|
||||
// already prove RequireRole rejects RoleService in isolation
|
||||
// (TestRequireRolePlainDoesNotAllowService); this proves it holds
|
||||
// through the real dashboards handler, wired the way it's actually
|
||||
// deployed, not just the middleware function in isolation. A defect
|
||||
// here would mean /alerting's service token -- meant only for POST
|
||||
// /query -- could also read/write dashboards, which was never the
|
||||
// intent (dashboards uses plain RequireRole, not RequireRoleOrService,
|
||||
// specifically to keep this door shut).
|
||||
func TestServiceIdentityCannotAccessDashboards(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs,
|
||||
&fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}})
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
for _, tt := range []struct {
|
||||
method, path, body string
|
||||
}{
|
||||
{http.MethodGet, "/dashboards", ""},
|
||||
{http.MethodGet, "/dashboards/dash-1", ""},
|
||||
{http.MethodPost, "/dashboards", `{"name": "New"}`},
|
||||
{http.MethodDelete, "/dashboards/dash-1", ""},
|
||||
} {
|
||||
rec := doRequest(t, mux, tt.method, tt.path, tt.body)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s: status = %d, want 403 (RoleService must never access dashboards)", tt.method, tt.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# Tenant isolation design
|
||||
|
||||
> **Status:** Design, awaiting sign-off. Task 2 of Phase 4 — the highest-
|
||||
> risk decision in the project so far, per explicit instruction: stop
|
||||
> here before any code is written. This document was pressure-tested by
|
||||
> an adversarial design review before being written up (not just
|
||||
> reasoned through once and accepted) — several of the "required design
|
||||
> elements" below exist specifically because that review found concrete
|
||||
> bypass scenarios in an earlier draft, not because they're generically
|
||||
> prudent. If implementation reveals this design is wrong somewhere, fix
|
||||
> this doc in the same change — same discipline as every prior phase's
|
||||
> design docs.
|
||||
|
||||
## Why this design, in one paragraph
|
||||
|
||||
Phases 0–3 are entirely single-tenant with zero authentication anywhere.
|
||||
Phase 4 needs to isolate tenant data with a security-review-credible
|
||||
guarantee, and the central fact shaping everything below is that Phase
|
||||
2's query language has a raw-SQL escape hatch that is deliberately
|
||||
*opaque* — never parsed, never validated against a schema
|
||||
(`/docs/query-language-design.md`). That single fact rules out row-level
|
||||
filtering (a `tenant_id` column plus a compiler-injected `WHERE` clause)
|
||||
as the *sole* isolation mechanism: a filter the compiler injects
|
||||
categorically cannot apply to a query the compiler never parses. So the
|
||||
real isolation boundary has to live one layer down, at the database
|
||||
connection itself — a tenant's ClickHouse user simply has no grant to
|
||||
read another tenant's database, and no application code, compiled query
|
||||
or hand-written SQL, can change that. Everything else in this document
|
||||
is either implementing that connection-layer boundary correctly or
|
||||
closing a gap the adversarial review found in a naive version of it.
|
||||
|
||||
## Module placement: `enterprise/` only, confirmed with the project owner
|
||||
|
||||
The tenant-isolation mechanism described here — per-tenant ClickHouse
|
||||
database/user, per-tenant Tantivy index, the `TenantID` plumbing —
|
||||
ships entirely in `enterprise/`, not AGPL core. Core (`/api`,
|
||||
`/alerting`, `/web`) stays genuinely single-tenant: no multi-tenant
|
||||
mechanism present at all, not merely a missing management UI on top of
|
||||
otherwise-functional isolation. This was an explicit choice put to the
|
||||
project owner rather than assumed, because CLAUDE.md's licensing
|
||||
boundary text names multi-tenancy as enterprise-gated, and a
|
||||
"mechanism in core, feature in enterprise" split would have let a
|
||||
sufficiently motivated self-hosting AGPL user wire up real isolation
|
||||
without ever touching `enterprise/` — undermining that boundary in
|
||||
substance even while technically respecting the AGPL/commercial import
|
||||
graph. Confirmed: enterprise-only.
|
||||
|
||||
Mechanically, this works because `api/internal/querylang/executor`
|
||||
already defines the seam Phase 2 needs regardless of tenancy:
|
||||
|
||||
```go
|
||||
type SQLRunner interface {
|
||||
RunSQL(ctx context.Context, sql string) (*Result, error)
|
||||
}
|
||||
type SearchClient interface {
|
||||
Search(ctx context.Context, query string, limit uint32) ([]string, error)
|
||||
}
|
||||
```
|
||||
|
||||
`enterprise/` supplies tenant-scoped implementations of these same
|
||||
core-defined interfaces — Go interfaces don't require an import edge
|
||||
from core to enterprise, only enterprise importing core's *interface
|
||||
types*, the allowed direction. Core's `querylang`/`executor` packages
|
||||
need zero changes for tenancy; `ChRunner` (today's single-connection
|
||||
implementation, `api/internal/querylang/executor/chrunner.go`) stays
|
||||
exactly as it is for single-tenant deployments, and `enterprise/`
|
||||
provides an alternate implementation for multi-tenant ones.
|
||||
|
||||
## A framing correction, stated plainly rather than built around
|
||||
|
||||
The original task language asks for "compile-time... structurally
|
||||
impossible to bypass" enforcement in the query compiler. Given the
|
||||
raw-SQL passthrough above, that specific phrasing isn't achievable in
|
||||
any module — there's no parse step to inject a filter into. The
|
||||
achievable, honest version: **every code path, compiled query or raw
|
||||
SQL, is forced through a tenant-scoped connection that the database's
|
||||
own access-control system enforces.** The structural guarantee is at
|
||||
the connection/index layer, not the compiler layer. This document's
|
||||
"required design elements" are what make that connection-layer
|
||||
guarantee actually hold under concurrency, partial failure, and
|
||||
ClickHouse's own default-permissive corners — not decoration on top of
|
||||
an already-sufficient row filter.
|
||||
|
||||
## ClickHouse: database-per-tenant, grant-enforced
|
||||
|
||||
One ClickHouse database + one dedicated, narrowly-granted ClickHouse
|
||||
user per tenant, on the shared cluster by default. A tenant can later be
|
||||
pinned to dedicated cluster nodes (Phase 4 task 6, a deployment-topology
|
||||
decision) for large/regulated customers — that changes *where* a
|
||||
tenant's database physically runs, not this model. `enterprise/` holds a
|
||||
small map of per-tenant `*ChRunner`s — today's `chrunner.go` shape (one
|
||||
`driver.Conn`, `Auth.Database` fixed at construction) is already exactly
|
||||
right, this just needs N of them instead of one. **No `tenant_id`
|
||||
column on `logs`**: isolation is a connection-level property, so there
|
||||
is nothing else in a tenant's own database to filter or leak through a
|
||||
missed `WHERE` clause.
|
||||
|
||||
### Required design elements
|
||||
|
||||
Each of these closes a specific bypass the adversarial review found in
|
||||
a naive version of "just give each tenant a database":
|
||||
|
||||
**1. No tenant traffic ever authenticates as ClickHouse's `default`
|
||||
user.** Today's `docker-compose.yml` sets `CLICKHOUSE_PASSWORD` on the
|
||||
implicit `default` user for the whole stack — a Phase 0–3-appropriate
|
||||
shortcut that must not carry into tenant-scoped connections. `default`
|
||||
(or an equivalent broad-access account) is reserved for
|
||||
migrations/provisioning/ops only, never handed to a request-serving
|
||||
code path.
|
||||
|
||||
**2. `system.*` access is explicitly revoked from every tenant user,
|
||||
not left at whatever ClickHouse's default template grants.**
|
||||
`system.query_log` records every query's full text by default, and is
|
||||
broadly readable unless explicitly revoked — so even with per-database
|
||||
row isolation working *perfectly*, a tenant able to read
|
||||
`system.query_log` can see other tenants' query text: predicate values,
|
||||
field names, sometimes literally sensitive data embedded in a `WHERE`
|
||||
clause. `SHOW DATABASES`/`system.tables` visibility being properly
|
||||
grant-scoped is version-dependent on `access_management` actually being
|
||||
engaged for the account, not the default `users.xml`-style setup. This
|
||||
is not assumed from documentation — it's verified by an adversarial
|
||||
integration test (Phase 4 task 8) that, as a tenant-scoped user,
|
||||
attempts `SELECT * FROM system.query_log`, `SELECT * FROM
|
||||
system.tables`, `SHOW DATABASES`, and a fully-qualified cross-tenant
|
||||
`SELECT * FROM <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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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** |
|
||||
@@ -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"]
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Executable
+78
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user