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
|
## 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
|
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
|
live data), save an alert rule that fires a Slack webhook when a
|
||||||
condition is met (threshold comparison, or "absence" — the query returned
|
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):
|
Non-goals for this phase (same discipline as every phase so far):
|
||||||
- No multi-tenancy enforcement and no `enterprise/` module work — single
|
- No multi-tenancy enforcement and no `enterprise/` module work — single
|
||||||
tenant/org assumed. New tables carry a `tenant_id` column so Phase 4's
|
tenant/org assumed. Most new tables (`dashboards`, `alert_rules`,
|
||||||
retrofit doesn't require a schema migration + backfill, but nothing
|
`notification_targets`) carry a `tenant_id` column so part of Phase 4's
|
||||||
reads or enforces it yet.
|
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
|
- No raw-SQL dashboard panels (time-range injection isn't reliable
|
||||||
against arbitrary SQL) — pipe-syntax queries only.
|
against arbitrary SQL) — pipe-syntax queries only.
|
||||||
- No per-group/multi-row threshold alerting (e.g. "alert separately per
|
- 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,
|
- No Kubernetes Operator/Helm deployment work — still docker-compose,
|
||||||
`/deploy` remains stubbed.
|
`/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
|
## When in doubt
|
||||||
Ask before: changing the pinned stack, adding a new external dependency
|
Ask before: changing the pinned stack, adding a new external dependency
|
||||||
that pulls in a large transitive tree, or making an architectural decision
|
that pulls in a large transitive tree, or making an architectural decision
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func main() {
|
|||||||
|
|
||||||
rules := rulestore.NewStore(pgPool)
|
rules := rulestore.NewStore(pgPool)
|
||||||
targets := notifystore.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)
|
handler := httpapi.NewHandler(logger, rules, targets, rules)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Config struct {
|
|||||||
HTTPListenAddr string
|
HTTPListenAddr string
|
||||||
Postgres PostgresConfig
|
Postgres PostgresConfig
|
||||||
APIQueryURL string // base URL of /api, e.g. http://api:8080 -- alerting never talks to ClickHouse/Tantivy directly
|
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
|
CORSAllowedOrigin string
|
||||||
Evaluator EvaluatorConfig
|
Evaluator EvaluatorConfig
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,10 @@ func Load() (Config, error) {
|
|||||||
Password: getenv("POSTGRES_PASSWORD", ""),
|
Password: getenv("POSTGRES_PASSWORD", ""),
|
||||||
},
|
},
|
||||||
APIQueryURL: getenv("API_QUERY_URL", "http://localhost:8080"),
|
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 --
|
// Same "no auth yet" tradeoff as api's CORSAllowedOrigin default --
|
||||||
// see api/internal/config/config.go's comment, same reasoning here.
|
// see api/internal/config/config.go's comment, same reasoning here.
|
||||||
CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"),
|
CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"),
|
||||||
|
|||||||
@@ -24,11 +24,19 @@ type errorResponse struct {
|
|||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
serviceToken string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(baseURL string) *Client {
|
// New builds a client for /api's POST /query. serviceToken, if non-empty,
|
||||||
return &Client{baseURL: baseURL, http: &http.Client{}}
|
// 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
|
// 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)
|
return nil, fmt.Errorf("building query request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.serviceToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.serviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
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/ClickHouse/clickhouse-go/v2"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/internal/authz"
|
||||||
"github.com/sentry/sentry/api/internal/config"
|
"github.com/sentry/sentry/api/internal/config"
|
||||||
"github.com/sentry/sentry/api/internal/dashboards"
|
"github.com/sentry/sentry/api/internal/dashboards"
|
||||||
"github.com/sentry/sentry/api/internal/httpserver"
|
"github.com/sentry/sentry/api/internal/httpserver"
|
||||||
@@ -88,9 +89,19 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
sqlRunner := executor.NewChRunner(conn)
|
||||||
queryHandler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout)
|
// audit logging is nil (a no-op) until Phase 4 task 5 wires in
|
||||||
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool))
|
// 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
|
// One shared mux, CORS applied once around the whole thing -- see
|
||||||
// internal/httpserver's doc comment for why this changed from each
|
// 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
|
SearchGRPCAddr string
|
||||||
QueryTimeout time.Duration
|
QueryTimeout time.Duration
|
||||||
CORSAllowedOrigin string
|
CORSAllowedOrigin string
|
||||||
|
EnterpriseAuthURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClickHouseConfig struct {
|
type ClickHouseConfig struct {
|
||||||
@@ -58,6 +59,12 @@ func Load() (Config, error) {
|
|||||||
// working out of the box. Tighten before this is ever reachable
|
// working out of the box. Tighten before this is ever reachable
|
||||||
// from outside a trusted dev/homelab network.
|
// from outside a trusted dev/homelab network.
|
||||||
CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"),
|
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"))
|
timeoutSec, err := strconv.Atoi(getenv("QUERY_TIMEOUT_SECONDS", "30"))
|
||||||
|
|||||||
@@ -6,47 +6,80 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/internal/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// store is the narrow interface Handler depends on -- *Store (store.go)
|
// store is the narrow interface Handler depends on -- *Store (store.go)
|
||||||
// is the production implementation; tests use a fake, same pattern as
|
// 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 {
|
type store interface {
|
||||||
CreateDashboard(ctx context.Context, d *Dashboard) error
|
CreateDashboard(ctx context.Context, d *Dashboard) error
|
||||||
ListDashboards(ctx context.Context) ([]Dashboard, error)
|
ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error)
|
||||||
GetDashboard(ctx context.Context, id string) (*Dashboard, error)
|
GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error)
|
||||||
UpdateDashboard(ctx context.Context, d *Dashboard) error
|
UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error
|
||||||
DeleteDashboard(ctx context.Context, id string) error
|
DeleteDashboard(ctx context.Context, tenantID, id string) error
|
||||||
AddPanel(ctx context.Context, dashboardID string, p *Panel) error
|
AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error
|
||||||
UpdatePanel(ctx context.Context, p *Panel) error
|
UpdatePanel(ctx context.Context, tenantID string, p *Panel) error
|
||||||
DeletePanel(ctx context.Context, dashboardID, panelID string) error
|
DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error
|
||||||
ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error)
|
ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
store store
|
store store
|
||||||
|
authorizer authz.Authorizer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(logger *slog.Logger, store store) *Handler {
|
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler {
|
||||||
return &Handler{logger: logger, store: store}
|
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) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("POST /dashboards", h.handleCreate)
|
mux.HandleFunc("POST /dashboards", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleCreate))
|
||||||
mux.HandleFunc("GET /dashboards", h.handleList)
|
mux.HandleFunc("GET /dashboards", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList))
|
||||||
mux.HandleFunc("POST /dashboards/import", h.handleImport)
|
mux.HandleFunc("POST /dashboards/import", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleImport))
|
||||||
mux.HandleFunc("GET /dashboards/{id}", h.handleGet)
|
mux.HandleFunc("GET /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGet))
|
||||||
mux.HandleFunc("PUT /dashboards/{id}", h.handleUpdate)
|
mux.HandleFunc("PUT /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdate))
|
||||||
mux.HandleFunc("DELETE /dashboards/{id}", h.handleDelete)
|
mux.HandleFunc("DELETE /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDelete))
|
||||||
mux.HandleFunc("GET /dashboards/{id}/export", h.handleExport)
|
mux.HandleFunc("GET /dashboards/{id}/export", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleExport))
|
||||||
mux.HandleFunc("POST /dashboards/{id}/panels", h.handleAddPanel)
|
mux.HandleFunc("POST /dashboards/{id}/panels", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleAddPanel))
|
||||||
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", h.handleUpdatePanel)
|
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdatePanel))
|
||||||
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", h.handleDeletePanel)
|
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
|
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) {
|
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
var d Dashboard
|
var d Dashboard
|
||||||
if !decodeJSON(w, r, &d) {
|
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")
|
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||||
return
|
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 {
|
if err := h.store.CreateDashboard(r.Context(), &d); err != nil {
|
||||||
h.logger.Error("creating dashboard", "error", err)
|
h.logger.Error("creating dashboard", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "creating dashboard failed")
|
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) {
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("listing dashboards", "error", err)
|
h.logger.Error("listing dashboards", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "listing dashboards failed")
|
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) {
|
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 {
|
if err != nil {
|
||||||
h.writeStoreErr(w, err, "fetching dashboard")
|
h.writeStoreErr(w, err, "fetching dashboard")
|
||||||
return
|
return
|
||||||
@@ -100,7 +135,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
imported, err := h.store.ImportDashboard(r.Context(), &d)
|
imported, err := h.store.ImportDashboard(r.Context(), h.tenantID(r), &d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("importing dashboard", "error", err)
|
h.logger.Error("importing dashboard", "error", err)
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
@@ -119,7 +154,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.ID = r.PathValue("id")
|
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")
|
h.writeStoreErr(w, err, "updating dashboard")
|
||||||
return
|
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) {
|
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")
|
h.writeStoreErr(w, err, "deleting dashboard")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -143,9 +178,8 @@ func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.store.AddPanel(r.Context(), r.PathValue("id"), &p); err != nil {
|
if err := h.store.AddPanel(r.Context(), h.tenantID(r), r.PathValue("id"), &p); err != nil {
|
||||||
h.logger.Error("adding panel", "error", err)
|
h.writeStoreErr(w, err, "adding panel")
|
||||||
writeError(w, http.StatusInternalServerError, "adding panel failed")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, p)
|
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.ID = r.PathValue("panelId")
|
||||||
p.DashboardID = r.PathValue("id")
|
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")
|
h.writeStoreErr(w, err, "updating panel")
|
||||||
return
|
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) {
|
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")
|
h.writeStoreErr(w, err, "deleting panel")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 {
|
type fakeStore struct {
|
||||||
dashboards map[string]*Dashboard
|
dashboards map[string]*Dashboard
|
||||||
createErr error
|
createErr error
|
||||||
@@ -31,44 +38,48 @@ func (f *fakeStore) CreateDashboard(_ context.Context, d *Dashboard) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ListDashboards(_ context.Context) ([]Dashboard, error) {
|
func (f *fakeStore) ListDashboards(_ context.Context, tenantID string) ([]Dashboard, error) {
|
||||||
var out []Dashboard
|
var out []Dashboard
|
||||||
for _, d := range f.dashboards {
|
for _, d := range f.dashboards {
|
||||||
|
if d.TenantID == tenantID {
|
||||||
out = append(out, *d)
|
out = append(out, *d)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return out, nil
|
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]
|
d, ok := f.dashboards[id]
|
||||||
if !ok {
|
if !ok || d.TenantID != tenantID {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return d, nil
|
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]
|
existing, ok := f.dashboards[d.ID]
|
||||||
if !ok {
|
if !ok || existing.TenantID != tenantID {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
panels := existing.Panels
|
panels := existing.Panels
|
||||||
*existing = *d
|
*existing = *d
|
||||||
|
existing.TenantID = tenantID
|
||||||
existing.Panels = panels
|
existing.Panels = panels
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) DeleteDashboard(_ context.Context, id string) error {
|
func (f *fakeStore) DeleteDashboard(_ context.Context, tenantID, id string) error {
|
||||||
if _, ok := f.dashboards[id]; !ok {
|
d, ok := f.dashboards[id]
|
||||||
|
if !ok || d.TenantID != tenantID {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
delete(f.dashboards, id)
|
delete(f.dashboards, id)
|
||||||
return nil
|
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]
|
d, ok := f.dashboards[dashboardID]
|
||||||
if !ok {
|
if !ok || d.TenantID != tenantID {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
p.ID = "panel-1"
|
p.ID = "panel-1"
|
||||||
@@ -77,9 +88,9 @@ func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) er
|
|||||||
return nil
|
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]
|
d, ok := f.dashboards[p.DashboardID]
|
||||||
if !ok {
|
if !ok || d.TenantID != tenantID {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
for i := range d.Panels {
|
for i := range d.Panels {
|
||||||
@@ -91,9 +102,9 @@ func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error {
|
|||||||
return ErrNotFound
|
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]
|
d, ok := f.dashboards[dashboardID]
|
||||||
if !ok {
|
if !ok || d.TenantID != tenantID {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
for i := range d.Panels {
|
for i := range d.Panels {
|
||||||
@@ -105,18 +116,39 @@ func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string)
|
|||||||
return ErrNotFound
|
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 {
|
if f.importErr != nil {
|
||||||
return nil, f.importErr
|
return nil, f.importErr
|
||||||
}
|
}
|
||||||
imported := *d
|
imported := *d
|
||||||
imported.ID = "dash-imported"
|
imported.ID = "dash-imported"
|
||||||
|
imported.TenantID = tenantID
|
||||||
f.dashboards[imported.ID] = &imported
|
f.dashboards[imported.ID] = &imported
|
||||||
return &imported, nil
|
return &imported, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestMux(fs *fakeStore) *http.ServeMux {
|
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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
return 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) {
|
func TestCreateDashboardRejectsEmptyName(t *testing.T) {
|
||||||
mux := newTestMux(newFakeStore())
|
mux := newTestMux(newFakeStore())
|
||||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": ""}`)
|
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) {
|
func TestAddPanelRejectsRawSQL(t *testing.T) {
|
||||||
fs := newFakeStore()
|
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)
|
mux := newTestMux(fs)
|
||||||
|
|
||||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||||
@@ -179,7 +308,7 @@ func TestAddPanelRejectsRawSQL(t *testing.T) {
|
|||||||
|
|
||||||
func TestAddPanelRejectsInvalidVizType(t *testing.T) {
|
func TestAddPanelRejectsInvalidVizType(t *testing.T) {
|
||||||
fs := newFakeStore()
|
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)
|
mux := newTestMux(fs)
|
||||||
|
|
||||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||||
@@ -191,7 +320,7 @@ func TestAddPanelRejectsInvalidVizType(t *testing.T) {
|
|||||||
|
|
||||||
func TestAddPanelSuccess(t *testing.T) {
|
func TestAddPanelSuccess(t *testing.T) {
|
||||||
fs := newFakeStore()
|
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)
|
mux := newTestMux(fs)
|
||||||
|
|
||||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||||
@@ -206,7 +335,7 @@ func TestAddPanelSuccess(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateDashboardChangesTimeRange(t *testing.T) {
|
func TestUpdateDashboardChangesTimeRange(t *testing.T) {
|
||||||
fs := newFakeStore()
|
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)
|
mux := newTestMux(fs)
|
||||||
|
|
||||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1",
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1",
|
||||||
@@ -229,7 +358,7 @@ func TestUpdateDashboardNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestDeleteDashboard(t *testing.T) {
|
func TestDeleteDashboard(t *testing.T) {
|
||||||
fs := newFakeStore()
|
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)
|
mux := newTestMux(fs)
|
||||||
|
|
||||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
||||||
@@ -244,7 +373,7 @@ func TestDeleteDashboard(t *testing.T) {
|
|||||||
func TestExportThenImportRoundTrips(t *testing.T) {
|
func TestExportThenImportRoundTrips(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
fs.dashboards["dash-1"] = &Dashboard{
|
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}},
|
Panels: []Panel{{ID: "panel-1", DashboardID: "dash-1", Query: "service=api", VizType: VizTable}},
|
||||||
}
|
}
|
||||||
mux := newTestMux(fs)
|
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) {
|
func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
fs.createErr = errors.New("boom")
|
fs.createErr = errors.New("boom")
|
||||||
@@ -277,3 +428,37 @@ func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 500", rec.Code)
|
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"
|
"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")
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
// Store is the pgx-backed CRUD implementation. IDs are assigned
|
// Store is the pgx-backed CRUD implementation. IDs are assigned
|
||||||
// server-side (google/uuid), matching how /ingest assigns record_id --
|
// server-side (google/uuid), matching how /ingest assigns record_id --
|
||||||
// one place (Go) generates IDs, not split between the app and the
|
// one place (Go) generates IDs, not split between the app and the
|
||||||
// database via a Postgres extension.
|
// 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 {
|
type Store struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -25,6 +42,10 @@ func NewStore(pool *pgxpool.Pool) *Store {
|
|||||||
return &Store{pool: pool}
|
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 {
|
func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error {
|
||||||
d.ID = uuid.NewString()
|
d.ID = uuid.NewString()
|
||||||
if d.TenantID == "" {
|
if d.TenantID == "" {
|
||||||
@@ -47,10 +68,10 @@ func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error {
|
|||||||
return row.Scan(&d.CreatedAt, &d.UpdatedAt)
|
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, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -67,11 +88,11 @@ func (s *Store) ListDashboards(ctx context.Context) ([]Dashboard, error) {
|
|||||||
return out, rows.Err()
|
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
|
var d Dashboard
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at
|
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 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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -87,6 +108,10 @@ func (s *Store) GetDashboard(ctx context.Context, id string) (*Dashboard, error)
|
|||||||
return &d, nil
|
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) {
|
func (s *Store) listPanels(ctx context.Context, dashboardID string) ([]Panel, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, dashboard_id, title, query, query_language, viz_type, viz_config,
|
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()
|
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 == "" {
|
if d.DefaultEarliest == "" {
|
||||||
d.DefaultEarliest = "-1h"
|
d.DefaultEarliest = "-1h"
|
||||||
}
|
}
|
||||||
@@ -120,9 +159,9 @@ func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error {
|
|||||||
}
|
}
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
UPDATE dashboards SET name = $1, description = $2, default_earliest = $3, default_latest = $4, updated_at = now()
|
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`,
|
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 err := row.Scan(&d.TenantID, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
@@ -132,8 +171,8 @@ func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteDashboard(ctx context.Context, id string) error {
|
func (s *Store) DeleteDashboard(ctx context.Context, tenantID, id string) error {
|
||||||
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1`, id)
|
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -143,7 +182,14 @@ func (s *Store) DeleteDashboard(ctx context.Context, id string) error {
|
|||||||
return nil
|
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.ID = uuid.NewString()
|
||||||
p.DashboardID = dashboardID
|
p.DashboardID = dashboardID
|
||||||
row := s.pool.QueryRow(ctx, `
|
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)
|
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, `
|
tag, err := s.pool.Exec(ctx, `
|
||||||
UPDATE dashboard_panels SET
|
UPDATE dashboard_panels SET
|
||||||
title = $1, query = $2, query_language = $3, viz_type = $4, viz_config = $5,
|
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
|
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)
|
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboard_panels WHERE id = $1 AND dashboard_id = $2`, panelID, dashboardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// importing an exported dashboard into a different environment (or
|
||||||
// re-importing into the same one) never collides with the source IDs.
|
// re-importing into the same one) never collides with the source IDs.
|
||||||
// Runs in one transaction: either the whole dashboard lands, or none of
|
// Runs in one transaction: either the whole dashboard lands, or none of
|
||||||
// it does.
|
// it does. tenantID comes from the caller (the authenticated identity),
|
||||||
func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error) {
|
// 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)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -200,10 +264,6 @@ func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard,
|
|||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
tenantID := d.TenantID
|
|
||||||
if tenantID == "" {
|
|
||||||
tenantID = "default"
|
|
||||||
}
|
|
||||||
createdBy := d.CreatedBy
|
createdBy := d.CreatedBy
|
||||||
if createdBy == "" {
|
if createdBy == "" {
|
||||||
createdBy = "anonymous"
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/internal/authz"
|
||||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
"github.com/sentry/sentry/api/internal/querylang/executor"
|
||||||
"github.com/sentry/sentry/api/internal/querylang/planner"
|
"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 {
|
type Handler struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
sqlRunner executor.SQLRunner
|
sqlRunner executor.SQLRunner
|
||||||
search executor.SearchClient
|
search executor.SearchClient
|
||||||
queryTimeout time.Duration
|
queryTimeout time.Duration
|
||||||
|
audit AuditLogger
|
||||||
|
authorizer authz.Authorizer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration) *Handler {
|
// audit and authorizer may both be nil -- see AuditLogger's doc comment
|
||||||
return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout}
|
// 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
|
// 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
|
// than by each handler wrapping itself individually -- see
|
||||||
// httpserver.WithCORS.
|
// httpserver.WithCORS.
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
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)
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), h.queryTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
result, err := executor.Execute(ctx, plan, h.sqlRunner, h.search)
|
result, err := executor.Execute(ctx, plan, h.sqlRunner, h.search)
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("query execution failed", "query", req.Query, "error", err)
|
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())
|
writeError(w, http.StatusBadGateway, "query failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.logAudit(r.Context(), req, len(result.Rows), duration, nil)
|
||||||
writeJSON(w, queryResponse{Columns: result.Columns, Rows: result.Rows})
|
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) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sentry/sentry/api/internal/authz"
|
||||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
"github.com/sentry/sentry/api/internal/querylang/executor"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +51,21 @@ func newTestHandler(sqlRunner *fakeSQLRunner, search *fakeSearchClient) *Handler
|
|||||||
if search == nil {
|
if search == nil {
|
||||||
search = &fakeSearchClient{}
|
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 {
|
func newTestMux(h *Handler) *http.ServeMux {
|
||||||
@@ -207,3 +222,121 @@ func TestHandleHealthz(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
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
|
return 1
|
||||||
}
|
}
|
||||||
alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv)
|
alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv)
|
||||||
|
token := resolveToken(os.Getenv)
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
return httpGetJSON(alertingURL, "/rules", stdout, stderr)
|
return httpGetJSON(alertingURL, "/rules", token, stdout, stderr)
|
||||||
case "get":
|
case "get":
|
||||||
if len(rest) == 0 {
|
if len(rest) == 0 {
|
||||||
fmt.Fprintln(stderr, "sentryctl alerts get: missing rule id")
|
fmt.Fprintln(stderr, "sentryctl alerts get: missing rule id")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return httpGetJSON(alertingURL, "/rules/"+rest[0], stdout, stderr)
|
return httpGetJSON(alertingURL, "/rules/"+rest[0], token, stdout, stderr)
|
||||||
case "apply":
|
case "apply":
|
||||||
if len(rest) == 0 {
|
if len(rest) == 0 {
|
||||||
fmt.Fprintln(stderr, "sentryctl alerts apply: missing file path")
|
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
|
// POST /rules accepts the same shape it returns -- a rule
|
||||||
// definition file (query, condition, interval, notification
|
// definition file (query, condition, interval, notification
|
||||||
// target ID) applies directly with no reshaping.
|
// 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:
|
default:
|
||||||
fmt.Fprintf(stderr, "sentryctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0])
|
fmt.Fprintf(stderr, "sentryctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0])
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
|
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
|
||||||
|
token := resolveToken(os.Getenv)
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
return httpGetJSON(apiURL, "/dashboards", stdout, stderr)
|
return httpGetJSON(apiURL, "/dashboards", token, stdout, stderr)
|
||||||
case "get":
|
case "get":
|
||||||
if len(rest) == 0 {
|
if len(rest) == 0 {
|
||||||
fmt.Fprintln(stderr, "sentryctl dashboards get: missing dashboard id")
|
fmt.Fprintln(stderr, "sentryctl dashboards get: missing dashboard id")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return httpGetJSON(apiURL, "/dashboards/"+rest[0], stdout, stderr)
|
return httpGetJSON(apiURL, "/dashboards/"+rest[0], token, stdout, stderr)
|
||||||
case "apply":
|
case "apply":
|
||||||
if len(rest) == 0 {
|
if len(rest) == 0 {
|
||||||
fmt.Fprintln(stderr, "sentryctl dashboards apply: missing file path")
|
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
|
// The import endpoint consumes exactly the shape GET
|
||||||
// /dashboards/{id}/export produces and the web UI's Export JSON
|
// /dashboards/{id}/export produces and the web UI's Export JSON
|
||||||
// button downloads -- one JSON contract, three call sites.
|
// 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:
|
default:
|
||||||
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply)\n", args[0])
|
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply)\n", args[0])
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -26,8 +26,15 @@ func parsePingArgs(args []string, env func(string) string) string {
|
|||||||
func cmdPing(args []string, stdout, stderr io.Writer) int {
|
func cmdPing(args []string, stdout, stderr io.Writer) int {
|
||||||
apiURL := parsePingArgs(args, os.Getenv)
|
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}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
resp, err := client.Get(apiURL + "/healthz")
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "ping failed: %v\n", err)
|
fmt.Fprintf(stderr, "ping failed: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -72,8 +72,16 @@ func cmdQuery(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
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}
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "query failed: %v\n", err)
|
fmt.Fprintf(stderr, "query failed: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -12,11 +12,26 @@ import (
|
|||||||
|
|
||||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
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
|
// httpGetJSON GETs path and prints the pretty-printed JSON response to
|
||||||
// stdout, or the error body/status to stderr. Shared by dashboards/alerts
|
// stdout, or the error body/status to stderr. Shared by dashboards/alerts
|
||||||
// list and get, which otherwise differ only in path and resource name.
|
// list and get, which otherwise differ only in path and resource name.
|
||||||
func httpGetJSON(baseURL, path string, stdout, stderr io.Writer) int {
|
func httpGetJSON(baseURL, path, token string, stdout, stderr io.Writer) int {
|
||||||
resp, err := httpClient.Get(baseURL + path)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "request failed: %v\n", err)
|
fmt.Fprintf(stderr, "request failed: %v\n", err)
|
||||||
return 1
|
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
|
// 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
|
// produce/accept respectively). This is what makes "apply" the seed of a
|
||||||
// future Terraform provider: one JSON contract, multiple callers.
|
// 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)
|
body, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "reading %s: %v\n", file, err)
|
fmt.Fprintf(stderr, "reading %s: %v\n", file, err)
|
||||||
return 1
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "request failed: %v\n", err)
|
fmt.Fprintf(stderr, "request failed: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -73,7 +73,13 @@ Commands:
|
|||||||
|
|
||||||
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
|
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
|
||||||
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` 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 {
|
func resolveAPIURL(env func(string) string) string {
|
||||||
@@ -90,6 +96,14 @@ func resolveAlertingURL(env func(string) string) string {
|
|||||||
return defaultAlertingURL
|
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 {
|
type errorResponseBody struct {
|
||||||
Error string `json:"error"`
|
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_USER: "sentry"
|
||||||
POSTGRES_PASSWORD: "sentry-dev-only"
|
POSTGRES_PASSWORD: "sentry-dev-only"
|
||||||
POSTGRES_DATABASE: "sentry_metadata"
|
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:
|
ingest:
|
||||||
build:
|
build:
|
||||||
@@ -231,6 +236,34 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 30
|
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: web
|
context: web
|
||||||
@@ -241,10 +274,12 @@ services:
|
|||||||
# network's service DNS names.
|
# network's service DNS names.
|
||||||
VITE_API_BASE_URL: "http://localhost:8080"
|
VITE_API_BASE_URL: "http://localhost:8080"
|
||||||
VITE_ALERTING_API_BASE_URL: "http://localhost:8081"
|
VITE_ALERTING_API_BASE_URL: "http://localhost:8081"
|
||||||
|
VITE_ENTERPRISE_AUTH_BASE_URL: "http://localhost:8082"
|
||||||
container_name: sentry-web
|
container_name: sentry-web
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
- alerting
|
- alerting
|
||||||
|
- enterprise-auth
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
|
|||||||
+89
-22
@@ -1,9 +1,13 @@
|
|||||||
# Sentry Architecture
|
# Sentry Architecture
|
||||||
|
|
||||||
> **Status:** Draft, Phase 0 scope. Written from the project constraints and
|
> **Status:** Updated through Phase 4. The component map/diagram below is
|
||||||
> task list at kickoff, not transcribed from a pre-existing spec. Treat as a
|
> still the Phase 0 request path (agent → ingest → ClickHouse → api →
|
||||||
> starting point to correct, not a settled design — flag anything that
|
> web) — it was never redrawn for the full-text search, dashboards/
|
||||||
> doesn't match your intent before implementation leans on it further.
|
> 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
|
## 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
|
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
|
the structured columns (captured via the `Map` column and/or a raw
|
||||||
passthrough field).
|
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.
|
This split is not to be changed without discussion — see CLAUDE.md.
|
||||||
|
|
||||||
## Component responsibilities (Phase 0)
|
## Component responsibilities
|
||||||
|
|
||||||
| Component | Responsibility |
|
| 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`. |
|
| `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 the agent↔ingest gRPC service, versioned independently of either component. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
| `web` (SvelteKit) | Single page: SQL text box, submit, results table. No auth, no styling polish. |
|
| `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. |
|
||||||
| `cli` (`sentryctl`) | Stub. Single `ping` command for now. |
|
| `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). |
|
||||||
| `deploy` | Helm charts, k8s manifests. Stubbed in Phase 0; docker-compose is the real local/dev path. |
|
| `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
|
## Licensing boundary
|
||||||
|
|
||||||
AGPLv3 for core + agents. Enterprise features (SSO, multi-tenancy,
|
AGPLv3 for core + agents. Enterprise features (SSO, RBAC storage, audit
|
||||||
compliance) live under `enterprise/` (not yet created — out of scope for
|
logging) live under `enterprise/` (commercial license stub, added
|
||||||
Phase 0) under a commercial license stub. AGPL code must never import from
|
Phase 4). AGPL code must never import from `enterprise/` — enforced in
|
||||||
`enterprise/`. No enterprise-gated code exists yet in this repo; this
|
CI by `hack/check-tenant-boundary.sh`, which greps every build for the
|
||||||
section documents the boundary so nothing added later crosses it by
|
import edge. Where core needs a decision only `enterprise/` can make
|
||||||
accident.
|
(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
|
## Non-negotiables carried from CLAUDE.md
|
||||||
|
|
||||||
- Rust agent: statically linked musl, `x86_64-unknown-linux-musl` and
|
- Rust agent: statically linked musl, `x86_64-unknown-linux-musl` and
|
||||||
`aarch64-unknown-linux-musl`, no glibc runtime deps.
|
`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.
|
- Every UI action maps to a documented REST/gRPC call — no UI-only logic.
|
||||||
- Pinned stack (see CLAUDE.md table) — no substitutions without discussion.
|
- 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
|
Per `/CLAUDE.md`'s Phase 4 non-goals and `/docs/security/threat-model.md`:
|
||||||
search, the real SPL-like query language, enterprise module code.
|
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
|
## 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
|
## 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`)
|
- `dashboards`, `dashboard_panels` — owned by `/api` (`api/internal/dashboards`)
|
||||||
- `notification_targets`, `alert_rules`, `alert_state`, `delivery_log` —
|
- `notification_targets`, `alert_rules`, `alert_state`, `delivery_log` —
|
||||||
owned by `/alerting`
|
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 —
|
"Owned" here is a documentation convention, not a technical boundary —
|
||||||
both services connect to the same Postgres instance/database, each with
|
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
|
repo convention that only `/proto` is shared code (and even that isn't
|
||||||
shared logic, just generated bindings).
|
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
|
## Migration tooling: mirrors `/storage/migrate.sh`, not a framework
|
||||||
|
|
||||||
Same reasoning as `/storage/README.md`: pulling in `golang-migrate` for
|
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_USER` | `sentry` |
|
||||||
| `POSTGRES_PASSWORD` | (empty — must be set) |
|
| `POSTGRES_PASSWORD` | (empty — must be set) |
|
||||||
| `POSTGRES_DATABASE` | `sentry_metadata` |
|
| `POSTGRES_DATABASE` | `sentry_metadata` |
|
||||||
|
| `AUDIT_WRITER_PASSWORD` | `audit-writer-dev-only` |
|
||||||
|
|
||||||
The database itself isn't created by `migrate.sh` — the `postgres:16-alpine`
|
The database itself isn't created by `migrate.sh` — the `postgres:16-alpine`
|
||||||
image auto-creates `POSTGRES_DB` on first startup, unlike ClickHouse where
|
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_USER="${POSTGRES_USER:-sentry}"
|
||||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||||
POSTGRES_DATABASE="${POSTGRES_DATABASE:-sentry_metadata}"
|
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"
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
|
||||||
@@ -18,7 +25,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
MIGRATIONS_DIR="${SCRIPT_DIR}/migrations"
|
MIGRATIONS_DIR="${SCRIPT_DIR}/migrations"
|
||||||
|
|
||||||
psql_exec() {
|
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..."
|
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