diff --git a/docs/phase-4-runbook.md b/docs/phase-4-runbook.md index 8c51904..f912a78 100644 --- a/docs/phase-4-runbook.md +++ b/docs/phase-4-runbook.md @@ -836,14 +836,27 @@ Full accounting: `/docs/security/threat-model.md`. Headline items: `enterprise-auth` container instead of a stand-in. - No admin UI to create a `tenant_memberships` row, but §3a/§3b's manual SQL bootstrap is gone -- `enterprise-auth -create-tenant`/ - `-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant` - (offline operator flags, same shape as `-mint-service-token`) cover - create/grant/revoke/list. Changing a role after the fact is just - re-running `-grant-membership-*` with a different `-grant-membership- - role` (`SetMembership`'s upsert already supports it). `RevokeMembership` - refuses a tenant's current Owner (would leave `tenants.owner_user_id` - dangling) -- transferring ownership first has no flag yet, only - `rbacstore.SetOwner` at the storage layer. + `-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant`/ + `-transfer-owner-*` (offline operator flags, same shape as + `-mint-service-token`) cover create/grant/revoke/list/transfer-owner. + Changing a non-Owner role after the fact is just re-running + `-grant-membership-*` with a different `-grant-membership-role` + (`SetMembership`'s upsert already supports it). `RevokeMembership` + still refuses a tenant's current Owner (would leave + `tenants.owner_user_id` dangling), but that's no longer a dead end -- + `-transfer-owner-tenant`/`-transfer-owner-user-email` + (`rbacstore.TransferOwner`, this package's first use of a real + transaction: downgrades the current owner to admin, promotes the new + owner, and updates `tenants.owner_user_id` atomically) hands ownership + off first, and the now-downgraded former owner can be revoked + normally after that. `-grant-membership-role=owner` itself now refuses + when a *different* owner already exists, pointing at + `-transfer-owner-*` instead of silently leaving a stale + `tenant_memberships` row. Skip-gated live-Postgres tests + (`TestTransferOwnerMovesOwnershipAndDowngradesPreviousOwner` and two + refusal-path tests in `enterprise/internal/rbacstore/rbacstore_test.go`) + haven't run against a live database in this environment, same gap as + the rest of this phase's Postgres-backed pieces. - **Per-resource dashboard grants are now enforced** (`api/dashboards`' handler reads `dashboard_permissions` via `enterprise/internal/rbacstore.DashboardPermissions`, only when diff --git a/enterprise/README.md b/enterprise/README.md index d73ca9f..12a447e 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -284,7 +284,7 @@ this environment. ## Package layout ``` -cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features/authorize-ingest endpoints, -mint-service-token, -create-tenant, -grant-membership-*, -revoke-membership-*, -list-memberships-tenant, -create-ingest-credential-tenant, -list-ingest-credentials-tenant, -revoke-ingest-credential +cmd/enterprise-auth/ config loading, OIDC discovery at startup, health/authorize/features/authorize-ingest endpoints, -mint-service-token, -create-tenant, -grant-membership-*, -revoke-membership-*, -list-memberships-tenant, -transfer-owner-*, -create-ingest-credential-tenant, -list-ingest-credentials-tenant, -revoke-ingest-credential cmd/enterprise-api/ multi-tenant-aware alternative to api/cmd/api -- see its own doc comment cmd/enterprise-ingest/ multi-tenant-aware alternative to ingest -mode=consumer -- see its own doc comment internal/tenant/ the ID type -- see its package doc comment before touching it @@ -417,21 +417,37 @@ docker compose run --rm enterprise-auth \ docker compose run --rm enterprise-auth -list-memberships-tenant=acme docker compose run --rm enterprise-auth \ -revoke-membership-tenant=acme -revoke-membership-user-email=someone-else@example.com + +# Hand ownership to someone else -- the previous owner is downgraded to +# admin, not removed, so this doesn't need a -revoke-membership-* first: +docker compose run --rm enterprise-auth \ + -transfer-owner-tenant=acme -transfer-owner-user-email=new-owner@example.com ``` `-create-tenant` only touches `rbacstore` -- pair with `enterprise-api -provision-tenant` (below) for a tenant to actually be able to run queries, not just log in. `role=owner` also calls `SetOwner`, since a tenant's Owner is a dedicated `tenants.owner_user_id` column, not just -the highest `tenant_memberships` role -- `-revoke-membership-*` refuses -to revoke a tenant's current Owner for the same reason (transferring -ownership first has no flag yet, only `rbacstore.SetOwner` at the -storage layer). Changing a role is just re-running -`-grant-membership-*` with a different `-grant-membership-role` -(`SetMembership`'s upsert already supports it). Not yet built: a flag -for `dashboard_permissions` grants (those go through the HTTP endpoints -`api/dashboards`' handler now exposes -- `PUT`/`DELETE -/dashboards/{id}/permissions/{userId}`, `GET .../permissions`). +the highest `tenant_memberships` role -- but only for a tenant's *first* +owner assignment (it refuses if a *different* owner already exists, per +`rbacstore.TransferOwner`'s doc comment). `-revoke-membership-*` refuses +to revoke a tenant's current Owner for the same reason `tenants. +owner_user_id` can only ever name one user -- +`-transfer-owner-tenant`/`-transfer-owner-user-email` is the real +handoff: `rbacstore.TransferOwner` atomically downgrades the current +owner to `admin`, promotes the new owner, and updates +`tenants.owner_user_id`, all in one transaction (the first this package +uses -- every other mutation here is a single independent statement, +but leaving `owner_user_id` and `tenant_memberships` disagreeing mid- +operation is exactly the inconsistent state `RevokeMembership`'s doc +comment already worries about). Changing a non-Owner role is just +re-running `-grant-membership-*` with a different +`-grant-membership-role` (`SetMembership`'s upsert already supports +it). `dashboard_permissions` grants have no `enterprise-auth` flag and +don't need one -- `sentryctl dashboards permissions list|grant|revoke` +covers them over the HTTP endpoints `api/dashboards`' handler already +exposes (`PUT`/`DELETE /dashboards/{id}/permissions/{userId}`, +`GET .../permissions`), see `/cli/README.md`. ## Provisioning a tenant and running `enterprise-api` diff --git a/enterprise/cmd/enterprise-auth/main.go b/enterprise/cmd/enterprise-auth/main.go index 65be606..6ac4798 100644 --- a/enterprise/cmd/enterprise-auth/main.go +++ b/enterprise/cmd/enterprise-auth/main.go @@ -80,6 +80,8 @@ func main() { revokeTenant := flag.String("revoke-membership-tenant", "", "tenant id to revoke a membership from -- both -revoke-membership-* flags are required together") revokeUserEmail := flag.String("revoke-membership-user-email", "", "email of the user whose tenant_memberships row to delete") listMembershipsTenant := flag.String("list-memberships-tenant", "", "print every user with a membership in this tenant (id, email, display name, role) and exit") + transferOwnerTenant := flag.String("transfer-owner-tenant", "", "tenant id to transfer ownership in -- both -transfer-owner-* flags are required together") + transferOwnerUserEmail := flag.String("transfer-owner-user-email", "", "email of the existing member to make the new Owner -- the current owner is downgraded to admin, not removed; see rbacstore.TransferOwner") createIngestCredentialTenant := flag.String("create-ingest-credential-tenant", "", "mint a new ingest bearer token for this tenant, print it once, and exit -- see ingest/internal/grpcserver.TenantResolver") listIngestCredentialsTenant := flag.String("list-ingest-credentials-tenant", "", "print every ingest credential's id/created_at for this tenant (never the token itself -- only its hash is stored) and exit") revokeIngestCredential := flag.String("revoke-ingest-credential", "", "delete an ingest credential by id (see -list-ingest-credentials-tenant) and exit") @@ -136,6 +138,9 @@ func main() { if *listMembershipsTenant != "" { os.Exit(runListMemberships(ctx, logger, rbac, *listMembershipsTenant)) } + if *transferOwnerTenant != "" || *transferOwnerUserEmail != "" { + os.Exit(runTransferOwner(ctx, logger, rbac, *transferOwnerTenant, *transferOwnerUserEmail)) + } if *createIngestCredentialTenant != "" { os.Exit(runCreateIngestCredential(ctx, logger, rbac, *createIngestCredentialTenant)) } @@ -290,7 +295,8 @@ func runGrantMembership(ctx context.Context, logger *slog.Logger, rbac *rbacstor return 1 } - if _, err := rbac.GetTenant(ctx, tenantID); err != nil { + tenant, err := rbac.GetTenant(ctx, tenantID) + if err != nil { logger.Error("looking up tenant", "tenant_id", tenantID, "error", err) return 1 } @@ -303,6 +309,18 @@ func runGrantMembership(ctx context.Context, logger *slog.Logger, rbac *rbacstor } return 1 } + // role="owner" is only correct for a tenant's *first* owner + // assignment (SetMembership+SetOwner below, mirroring + // TransferOwner's doc comment on when each is appropriate) -- a + // second use of this flag while a *different* owner already exists + // would leave that owner's membership row stale at "owner" while + // tenants.owner_user_id points elsewhere, exactly the inconsistency + // TransferOwner exists to avoid. Refuse and point at the right flag + // instead of silently producing that state. + if rbacRole == rbacstore.RoleOwner && tenant.OwnerUserID != "" && tenant.OwnerUserID != user.ID { + logger.Error("tenant already has a different owner -- use -transfer-owner-tenant/-transfer-owner-user-email instead, which also downgrades the current owner", "tenant_id", tenantID, "current_owner_user_id", tenant.OwnerUserID) + return 1 + } if err := rbac.SetMembership(ctx, tenantID, user.ID, rbacRole); err != nil { logger.Error("setting membership", "error", err) @@ -375,6 +393,33 @@ func runListMemberships(ctx context.Context, logger *slog.Logger, rbac *rbacstor return 0 } +// runTransferOwner is the operator-facing side of the RBAC matrix's +// "Transfer tenant Owner -- Owner only" row -- rbacstore.TransferOwner +// does the actual atomic work (downgrade current owner, promote new +// owner, update tenants.owner_user_id); this just resolves the email to +// a user the same way runGrantMembership/runRevokeMembership do. +func runTransferOwner(ctx context.Context, logger *slog.Logger, rbac *rbacstore.Store, tenantID, userEmail string) int { + if tenantID == "" || userEmail == "" { + logger.Error("-transfer-owner-tenant and -transfer-owner-user-email are both required together") + return 1 + } + user, err := rbac.GetUserByEmail(ctx, userEmail) + if err != nil { + if err == rbacstore.ErrNotFound { + logger.Error("no user with this email exists -- they must attempt an SSO login at least once first", "email", userEmail) + } else { + logger.Error("looking up user by email", "email", userEmail, "error", err) + } + return 1 + } + if err := rbac.TransferOwner(ctx, tenantID, user.ID, rbacstore.RoleAdmin); err != nil { + logger.Error("transferring tenant ownership", "error", err) + return 1 + } + logger.Info("transferred tenant ownership", "tenant_id", tenantID, "new_owner_user_id", user.ID, "email", userEmail, "previous_owner_downgraded_to", rbacstore.RoleAdmin) + return 0 +} + // runCreateIngestCredential mints a new ingest bearer token for a // tenant and prints it to stdout exactly once -- rbacstore only ever // stores its hash (see ingest_credentials's doc comment), so this diff --git a/enterprise/internal/rbacstore/rbacstore.go b/enterprise/internal/rbacstore/rbacstore.go index cab8031..3c96b99 100644 --- a/enterprise/internal/rbacstore/rbacstore.go +++ b/enterprise/internal/rbacstore/rbacstore.go @@ -233,6 +233,63 @@ func (s *Store) SetOwner(ctx context.Context, tenantID, userID string) error { return nil } +// TransferOwner atomically moves a tenant's Owner from whoever currently +// holds tenants.owner_user_id to newOwnerUserID: downgrades the +// current owner's tenant_memberships row to downgradeRole (the RBAC +// matrix's "Transfer tenant Owner -- Owner only" -- the previous owner +// keeps a real role, typically RoleAdmin, rather than being silently +// ejected from the tenant), upserts the new owner's membership to +// RoleOwner, and updates tenants.owner_user_id -- all in one +// transaction, the first in this package. Every other mutation here is +// a single independent statement because nothing else needs more than +// one row to agree; this genuinely does, for the exact reason +// RevokeMembership's doc comment already gives: owner_user_id pointing +// at a user whose tenant_memberships row doesn't say 'owner' (or vice +// versa) is an inconsistent state, and calling SetOwner/SetMembership +// separately outside a transaction risks landing in it on any failure +// between the two calls, not just caller error. +func (s *Store) TransferOwner(ctx context.Context, tenantID, newOwnerUserID string, downgradeRole Role) error { + tenant, err := s.GetTenant(ctx, tenantID) + if err != nil { + return fmt.Errorf("rbacstore: getting tenant to transfer ownership: %w", err) + } + if tenant.OwnerUserID == "" { + return fmt.Errorf("rbacstore: tenant %q has no current owner -- use SetMembership+SetOwner directly for a first assignment, TransferOwner is for an existing owner handing off", tenantID) + } + if tenant.OwnerUserID == newOwnerUserID { + return fmt.Errorf("rbacstore: %q is already tenant %q's owner", newOwnerUserID, tenantID) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("rbacstore: beginning ownership transfer: %w", err) + } + defer tx.Rollback(ctx) // no-op once Commit succeeds + + upsertMembership := ` + 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()` + if _, err := tx.Exec(ctx, upsertMembership, uuid.NewString(), tenantID, tenant.OwnerUserID, string(downgradeRole)); err != nil { + return fmt.Errorf("rbacstore: downgrading previous owner's membership: %w", err) + } + if _, err := tx.Exec(ctx, upsertMembership, uuid.NewString(), tenantID, newOwnerUserID, string(RoleOwner)); err != nil { + return fmt.Errorf("rbacstore: setting new owner's membership: %w", err) + } + tag, err := tx.Exec(ctx, `UPDATE tenants SET owner_user_id = $2, updated_at = now() WHERE id = $1`, tenantID, newOwnerUserID) + if err != nil { + return fmt.Errorf("rbacstore: setting tenant owner: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("rbacstore: committing ownership transfer: %w", err) + } + 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 diff --git a/enterprise/internal/rbacstore/rbacstore_test.go b/enterprise/internal/rbacstore/rbacstore_test.go index 538f075..622eea3 100644 --- a/enterprise/internal/rbacstore/rbacstore_test.go +++ b/enterprise/internal/rbacstore/rbacstore_test.go @@ -180,6 +180,107 @@ func TestSetOwnerAndMembershipRoundTrip(t *testing.T) { } } +func TestTransferOwnerMovesOwnershipAndDowngradesPreviousOwner(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) + } + oldOwner, err := s.UpsertUserBySSO(ctx, "sub-old-owner-"+uniqueSuffix(), "old-owner-"+uniqueSuffix()+"@example.com", "Old Owner") + if err != nil { + t.Fatalf("UpsertUserBySSO (old owner): %v", err) + } + newOwner, err := s.UpsertUserBySSO(ctx, "sub-new-owner-"+uniqueSuffix(), "new-owner-"+uniqueSuffix()+"@example.com", "New Owner") + if err != nil { + t.Fatalf("UpsertUserBySSO (new owner): %v", err) + } + if err := s.SetMembership(ctx, tenantID, oldOwner.ID, RoleOwner); err != nil { + t.Fatalf("SetMembership: %v", err) + } + if err := s.SetOwner(ctx, tenantID, oldOwner.ID); err != nil { + t.Fatalf("SetOwner: %v", err) + } + + if err := s.TransferOwner(ctx, tenantID, newOwner.ID, RoleAdmin); err != nil { + t.Fatalf("TransferOwner: %v", err) + } + + tenant, err := s.GetTenant(ctx, tenantID) + if err != nil { + t.Fatalf("GetTenant: %v", err) + } + if tenant.OwnerUserID != newOwner.ID { + t.Fatalf("tenant OwnerUserID = %q, want %q", tenant.OwnerUserID, newOwner.ID) + } + + newOwnerMembership, err := s.GetMembership(ctx, tenantID, newOwner.ID) + if err != nil { + t.Fatalf("GetMembership (new owner): %v", err) + } + if newOwnerMembership.Role != RoleOwner { + t.Fatalf("new owner's role = %q, want owner", newOwnerMembership.Role) + } + + // The point of TransferOwner over calling SetOwner alone: the + // previous owner keeps a real membership (downgraded, not deleted or + // left stale at "owner") -- otherwise tenant_memberships would claim + // two owners while tenants.owner_user_id can only name one. + oldOwnerMembership, err := s.GetMembership(ctx, tenantID, oldOwner.ID) + if err != nil { + t.Fatalf("GetMembership (old owner): %v", err) + } + if oldOwnerMembership.Role != RoleAdmin { + t.Fatalf("old owner's role after transfer = %q, want admin (downgraded, not left as owner)", oldOwnerMembership.Role) + } + + // The previous owner is no longer Owner, so RevokeMembership must now + // accept revoking them -- proves the downgrade is real, not cosmetic. + if err := s.RevokeMembership(ctx, tenantID, oldOwner.ID); err != nil { + t.Fatalf("RevokeMembership on the downgraded former owner: %v", err) + } +} + +func TestTransferOwnerRefusesTenantWithNoCurrentOwner(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) + } + newOwner, err := s.UpsertUserBySSO(ctx, "sub-"+uniqueSuffix(), "user-"+uniqueSuffix()+"@example.com", "Someone") + if err != nil { + t.Fatalf("UpsertUserBySSO: %v", err) + } + + if err := s.TransferOwner(ctx, tenantID, newOwner.ID, RoleAdmin); err == nil { + t.Fatal("expected TransferOwner to refuse a tenant with no current owner") + } +} + +func TestTransferOwnerRefusesTransferToCurrentOwner(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) + } + owner, err := s.UpsertUserBySSO(ctx, "sub-"+uniqueSuffix(), "owner-"+uniqueSuffix()+"@example.com", "Owner") + if err != nil { + t.Fatalf("UpsertUserBySSO: %v", err) + } + if err := s.SetMembership(ctx, tenantID, owner.ID, RoleOwner); err != nil { + t.Fatalf("SetMembership: %v", err) + } + if err := s.SetOwner(ctx, tenantID, owner.ID); err != nil { + t.Fatalf("SetOwner: %v", err) + } + + if err := s.TransferOwner(ctx, tenantID, owner.ID, RoleAdmin); err == nil { + t.Fatal("expected TransferOwner to refuse transferring ownership to its current holder") + } +} + func TestGetMembershipNotFound(t *testing.T) { s := testStore(t) ctx := context.Background()