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
@@ -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)
}
}