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

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

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

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+134 -1
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/sentry/sentry/api/internal/authz"
"github.com/sentry/sentry/api/internal/querylang/executor"
)
@@ -50,7 +51,21 @@ func newTestHandler(sqlRunner *fakeSQLRunner, search *fakeSearchClient) *Handler
if search == nil {
search = &fakeSearchClient{}
}
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second)
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second, nil, nil)
}
type fakeAuditLogger struct {
entries []QueryAuditEntry
err error
}
func (f *fakeAuditLogger) LogQuery(_ context.Context, entry QueryAuditEntry) error {
f.entries = append(f.entries, entry)
return f.err
}
func newTestHandlerWithAudit(sqlRunner *fakeSQLRunner, audit *fakeAuditLogger) *Handler {
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, &fakeSearchClient{}, time.Second, audit, nil)
}
func newTestMux(h *Handler) *http.ServeMux {
@@ -207,3 +222,121 @@ func TestHandleHealthz(t *testing.T) {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestHandleQueryLogsAuditEntryOnSuccess(t *testing.T) {
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{"host"}, Rows: [][]any{{"h1"}, {"h2"}}}}
audit := &fakeAuditLogger{}
h := newTestHandlerWithAudit(sr, audit)
rec := postQuery(t, h, `{"query": "SELECT host FROM logs"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if len(audit.entries) != 1 {
t.Fatalf("expected 1 audit entry, got %d", len(audit.entries))
}
entry := audit.entries[0]
if entry.Query != "SELECT host FROM logs" || !entry.Success || entry.RowCount != 2 || entry.Error != "" {
t.Fatalf("unexpected audit entry: %+v", entry)
}
}
func TestHandleQueryLogsAuditEntryOnFailure(t *testing.T) {
sr := &fakeSQLRunner{err: errors.New("boom")}
audit := &fakeAuditLogger{}
h := newTestHandlerWithAudit(sr, audit)
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rec.Code)
}
if len(audit.entries) != 1 {
t.Fatalf("expected 1 audit entry even on failure, got %d", len(audit.entries))
}
entry := audit.entries[0]
if entry.Success || entry.Error == "" {
t.Fatalf("expected a failed audit entry with an error message, got %+v", entry)
}
}
// TestHandleQueryAuditWriteFailureDoesNotFailRequest proves the
// fail-open design: a request still succeeds even when the audit
// logger itself errors -- per queryapi.AuditLogger's doc comment and
// /docs/phase-4-isolation-design.md's audit fail-open/fail-closed policy.
func TestHandleQueryAuditWriteFailureDoesNotFailRequest(t *testing.T) {
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
audit := &fakeAuditLogger{err: errors.New("audit backend unreachable")}
h := newTestHandlerWithAudit(sr, audit)
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 -- an audit write failure must not fail the request", rec.Code)
}
}
func TestHandleQueryNilAuditLoggerIsNoOp(t *testing.T) {
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
h := newTestHandler(sr, nil) // audit is nil here
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// fakeAuthorizer resolves every request to a fixed identity/error --
// task 5 wired authz.RequireRoleOrService into RegisterRoutes but every
// existing test above passes a nil authorizer (a deliberate no-op), so
// none of them actually exercise the wiring with a real authorizer
// present. Phase 4 task 8 (adversarial tests) closes that gap: these
// prove /query's authz boundary holds when a real Authorizer is wired
// in, not just that the middleware function works in isolation
// (authz/middleware_test.go already covers that).
type fakeAuthorizer struct {
identity authz.Identity
err error
}
func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
return f.identity, f.err
}
func newTestHandlerWithAuthorizer(sqlRunner *fakeSQLRunner, authorizer authz.Authorizer) *Handler {
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, &fakeSearchClient{}, time.Second, nil, authorizer)
}
func TestHandleQueryRejectsUnauthenticatedWhenAuthorizerConfigured(t *testing.T) {
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{err: errors.New("no session")})
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestHandleQueryAllowsViewer(t *testing.T) {
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{identity: authz.Identity{TenantID: "acme", Role: authz.RoleViewer}})
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
// TestHandleQueryAllowsServiceIdentity is the other half of the
// alerting<->api gap's fix (/docs/phase-4-isolation-design.md) --
// /alerting's evaluator must be able to call POST /query with its
// RoleService credential even though it's not a human session.
func TestHandleQueryAllowsServiceIdentity(t *testing.T) {
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}})
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 -- RoleService must be allowed on /query; body=%s", rec.Code, rec.Body.String())
}
}