Add rbacstore.TransferOwner and enterprise-auth -transfer-owner-*

RevokeMembership refuses to revoke a tenant's current Owner (would leave
tenants.owner_user_id dangling), but there was no way to actually hand
ownership to someone else -- only the raw SetOwner primitive, which
-grant-membership-role=owner used without downgrading whoever held
Owner before, leaving tenant_memberships claiming two owners while
owner_user_id can only name one. Named as a real, disclosed gap in
docs/phase-4-runbook.md's "Known gaps".

rbacstore.TransferOwner closes it: downgrades the current owner's
membership to admin, promotes the new owner, and updates
tenants.owner_user_id, all in one pgx transaction -- this package's
first use of one. Every other mutation here is a single independent
statement because nothing else needs more than one row to agree; this
does, for the same reason RevokeMembership's doc comment already gives
for refusing to revoke Owner in the first place.

-grant-membership-role=owner now refuses when a *different* owner
already exists, pointing at the new -transfer-owner-tenant/
-transfer-owner-user-email flags instead of silently producing the
inconsistent state -- it remains correct, unchanged, for a tenant's
first owner assignment.

Verified with the same skip-gated live-Postgres discipline as the rest
of this package (TestTransferOwnerMovesOwnershipAndDowngradesPrevious
Owner proves the downgrade is real by then successfully revoking the
former owner's now-non-Owner membership; two refusal-path tests cover
no-current-owner and transfer-to-self) -- not run against a live
database in this environment, same disclosed gap as everything else
here.
This commit is contained in:
2026-08-14 23:30:05 -07:00
parent 3cf1320881
commit 5a845f06ee
5 changed files with 251 additions and 19 deletions
+21 -8
View File
@@ -836,14 +836,27 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
`enterprise-auth` container instead of a stand-in. `enterprise-auth` container instead of a stand-in.
- No admin UI to create a `tenant_memberships` row, but §3a/§3b's manual - No admin UI to create a `tenant_memberships` row, but §3a/§3b's manual
SQL bootstrap is gone -- `enterprise-auth -create-tenant`/ SQL bootstrap is gone -- `enterprise-auth -create-tenant`/
`-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant` `-grant-membership-*`/`-revoke-membership-*`/`-list-memberships-tenant`/
(offline operator flags, same shape as `-mint-service-token`) cover `-transfer-owner-*` (offline operator flags, same shape as
create/grant/revoke/list. Changing a role after the fact is just `-mint-service-token`) cover create/grant/revoke/list/transfer-owner.
re-running `-grant-membership-*` with a different `-grant-membership- Changing a non-Owner role after the fact is just re-running
role` (`SetMembership`'s upsert already supports it). `RevokeMembership` `-grant-membership-*` with a different `-grant-membership-role`
refuses a tenant's current Owner (would leave `tenants.owner_user_id` (`SetMembership`'s upsert already supports it). `RevokeMembership`
dangling) -- transferring ownership first has no flag yet, only still refuses a tenant's current Owner (would leave
`rbacstore.SetOwner` at the storage layer. `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`' - **Per-resource dashboard grants are now enforced** (`api/dashboards`'
handler reads `dashboard_permissions` via handler reads `dashboard_permissions` via
`enterprise/internal/rbacstore.DashboardPermissions`, only when `enterprise/internal/rbacstore.DashboardPermissions`, only when
+26 -10
View File
@@ -284,7 +284,7 @@ this environment.
## Package layout ## 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-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 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 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 -list-memberships-tenant=acme
docker compose run --rm enterprise-auth \ docker compose run --rm enterprise-auth \
-revoke-membership-tenant=acme -revoke-membership-user-email=[email protected] -revoke-membership-tenant=acme -revoke-membership-user-email=[email protected]
# 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=[email protected]
``` ```
`-create-tenant` only touches `rbacstore` -- pair with `enterprise-api `-create-tenant` only touches `rbacstore` -- pair with `enterprise-api
-provision-tenant` (below) for a tenant to actually be able to run -provision-tenant` (below) for a tenant to actually be able to run
queries, not just log in. `role=owner` also calls `SetOwner`, since a 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 tenant's Owner is a dedicated `tenants.owner_user_id` column, not just
the highest `tenant_memberships` role -- `-revoke-membership-*` refuses the highest `tenant_memberships` role -- but only for a tenant's *first*
to revoke a tenant's current Owner for the same reason (transferring owner assignment (it refuses if a *different* owner already exists, per
ownership first has no flag yet, only `rbacstore.SetOwner` at the `rbacstore.TransferOwner`'s doc comment). `-revoke-membership-*` refuses
storage layer). Changing a role is just re-running to revoke a tenant's current Owner for the same reason `tenants.
`-grant-membership-*` with a different `-grant-membership-role` owner_user_id` can only ever name one user --
(`SetMembership`'s upsert already supports it). Not yet built: a flag `-transfer-owner-tenant`/`-transfer-owner-user-email` is the real
for `dashboard_permissions` grants (those go through the HTTP endpoints handoff: `rbacstore.TransferOwner` atomically downgrades the current
`api/dashboards`' handler now exposes -- `PUT`/`DELETE owner to `admin`, promotes the new owner, and updates
/dashboards/{id}/permissions/{userId}`, `GET .../permissions`). `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` ## Provisioning a tenant and running `enterprise-api`
+46 -1
View File
@@ -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") 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") 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") 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") 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") 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") 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 != "" { if *listMembershipsTenant != "" {
os.Exit(runListMemberships(ctx, logger, rbac, *listMembershipsTenant)) os.Exit(runListMemberships(ctx, logger, rbac, *listMembershipsTenant))
} }
if *transferOwnerTenant != "" || *transferOwnerUserEmail != "" {
os.Exit(runTransferOwner(ctx, logger, rbac, *transferOwnerTenant, *transferOwnerUserEmail))
}
if *createIngestCredentialTenant != "" { if *createIngestCredentialTenant != "" {
os.Exit(runCreateIngestCredential(ctx, logger, rbac, *createIngestCredentialTenant)) os.Exit(runCreateIngestCredential(ctx, logger, rbac, *createIngestCredentialTenant))
} }
@@ -290,7 +295,8 @@ func runGrantMembership(ctx context.Context, logger *slog.Logger, rbac *rbacstor
return 1 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) logger.Error("looking up tenant", "tenant_id", tenantID, "error", err)
return 1 return 1
} }
@@ -303,6 +309,18 @@ func runGrantMembership(ctx context.Context, logger *slog.Logger, rbac *rbacstor
} }
return 1 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 { if err := rbac.SetMembership(ctx, tenantID, user.ID, rbacRole); err != nil {
logger.Error("setting membership", "error", err) logger.Error("setting membership", "error", err)
@@ -375,6 +393,33 @@ func runListMemberships(ctx context.Context, logger *slog.Logger, rbac *rbacstor
return 0 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 // runCreateIngestCredential mints a new ingest bearer token for a
// tenant and prints it to stdout exactly once -- rbacstore only ever // tenant and prints it to stdout exactly once -- rbacstore only ever
// stores its hash (see ingest_credentials's doc comment), so this // stores its hash (see ingest_credentials's doc comment), so this
@@ -233,6 +233,63 @@ func (s *Store) SetOwner(ctx context.Context, tenantID, userID string) error {
return nil 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 // SetMembership upserts a user's role for a tenant -- the sole mutation
// path for tenant_memberships, so every role change naturally funnels // path for tenant_memberships, so every role change naturally funnels
// through one method a future audit-log hook (EventRoleChange, see // through one method a future audit-log hook (EventRoleChange, see
@@ -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) { func TestGetMembershipNotFound(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()