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).
228 lines
7.3 KiB
Go
228 lines
7.3 KiB
Go
// 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)
|
|
}
|
|
}
|