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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user