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.
- 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