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
+26 -10
View File
@@ -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=[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
-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`