Conversation
`assign_role` requires `organization.change_owner` inside the target org whenever the Owner role is involved (either as the role being assigned or because the subject is the current Owner). Requesters whose authority comes from a global role binding cannot satisfy that gate. Their permissions resolve from the user-only cache key (`RBI.generate_all_possible_keys/1`), so they hold no in-org binding that could ever carry `organization.change_owner` — even when their global role exists precisely to manage organization owners. The base authorization check passes for them (they hold `organization.people.manage`), so the request only fails once it reaches the owner gate. The gate now also accepts a requester holding `insider.owners.manage` globally. The check is permission-based: it reads the requester's permissions with the nil-uuid org id, exactly as `authorize!/3` already does for global-role requests, which resolves to the single user-only cache key. Nothing else changes: the base authorization call, the held-permissions check and the project-initialization bypass all behave as before. The gate moved into `authorize_owner_change!/4` to keep `assign_role` under the cyclomatic complexity limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
skipi
requested review from
DamjanBecirovic,
adbatista,
dexyk and
loadez
as code owners
August 19, 2026 06:44
skipi
marked this pull request as draft
August 19, 2026 07:11
Assigning the Owner role skips the held-permissions check, but a demotion assigns an ordinary role, so that check still ran and required the requester to hold every permission the target role grants. A global owner-management role does not hold them, so the previous commit restored promotion but not demotion. Skip the held-permissions check for an org-level owner change instead. That is safe because the Owner role holds every org-scope permission, so replacing an Owner's role cannot grant the subject anything they did not already have. The skip is scoped to org-level assignments, which also means a project-scope role named "Owner" now runs the check where it previously did not. The owner gate's condition is unchanged. It now receives the precomputed predicate, which removes a duplicate role lookup and keeps the project initialization path from computing it at all. Match the permission exactly rather than by substring: the value read from the permission store is comma-joined, so a future permission whose name merely contained this one would have passed the gate. Tests: demotion now proves the behaviour with a requester holding only the global role's permissions, where previously it passed because the fixture Member role granted a single permission while the real one grants more; an in-org binding carrying the permission must not pass the gate; a merely prefixed permission must not pass; a project-scope role named "Owner" runs the held-permissions check, with a positive counterpart pinning that test to the check rather than to the role name; and the Owner role grants every org-scope permission, the invariant the skip relies on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
assign_rolerequiresorganization.change_ownerinside the target organization whenever the Owner role is involved — either as the role being assigned, or because the subject is the current Owner.Requesters whose authority comes from a global role binding can never satisfy that gate. Their permissions resolve from the user-only cache key produced by
RBI.generate_all_possible_keys/1, so they hold no in-organization binding that could carryorganization.change_owner— even when the global role exists precisely to manage organization owners. The base authorization check passes for them (they holdorganization.people.manage), so the request only fails once it reaches the owner gate, returningPERMISSION_DENIED/ "User unauthorized".Change
The owner gate now also accepts a requester holding
insider.owners.manageglobally. The check is permission-based: it reads the requester's permissions with the nil-uuid organization id, exactly asauthorize!/3already does for global-role requests, which collapses all three cache keys into the single user-only key. An in-organization binding and a project binding therefore cannot satisfy it. A group holding a global role binding can, sinceQueries.user_to_subject_bindings_query/1expands a user to their group subjects and permissions are stored under the binding's ownorg_id— so the accepted authority is any global subject-role binding, not strictly a direct user one.The permission is matched by exact token membership. The value returned by the permission store is comma-joined, so a substring test would let a future permission whose name merely contains this one (say
insider.owners.manage.view) pass a gate whose failure mode is organization takeover.The held-permissions check is now skipped for an org-level owner change, not only for an Owner-role assignment. Without this, the change would restore promotion but not demotion: a demotion assigns an ordinary role such as Member, which is not Owner, so
authorize_holds_role_permissions!/4still ran and required the requester to hold every permission that role grants — which a global owner-management role does not. Skipping it is safe because the Owner role holds every org-scope permission (asserted by a new test), so replacing a current Owner's role cannot grant the subject anything they did not already have.Two consequences worth calling out explicitly:
get_role_by_idand avoids computing it at all on the project-initialization path. Notably the gate is not scoped toproject_id == "", so assigning a project role to a current Owner still requires the same authority it does today.owner_role?/1matches on name only, scope-agnostic, and the unique index is[:name, :org_id, :scope_id], so such a role can exist. This is a tightening, and it is covered by tests.Project initialization remains fully exempt, as before.
Tests
insider.owners.manageon a real global role that does not carryorganization.change_owner.insider.owners.managedoes not satisfy the gate — the regression guard for anyone later reaching fororg_idinstead ofnil_uuid().insider.owners.managedoes not satisfy the gate.project.delete, which Owner holds viamaps_torather than directly, so the assertion compares org-scope permissions.)Full
ee/rbacsuite green,mix format --check-formattedandmix credo --allclean. The two regression guards that cannot be red by construction were verified by mutation instead: changingnil_uuid()toorg_idturns the in-org test red, reverting the held-permissions condition turns the project-Owner test red while leaving its positive counterpart green, and removing a permission from Owner turns the invariant test red.Operational notes for reviewers
insider.owners.manageis not seeded by this repository.Rbac.Migrator.seed_data/0andRbac.OnPrem.Initcreate onlyorg_scopeandproject_scope, andRbac.Repo.Permission.insert_default_permissions/0reads only theorganizationandprojectsections ofassets/permissions.yaml— so a new top-level key there would be dead config, and putting the entry underorganizationwould seed it with the wrong scope and surface it in organization custom-role editors. A deployment that wants this path therefore creates the scope, permission and role binding itself, before any UI that gates on the permission ships. Installs with no global bindings are unaffected:insider_owners_manager?/1returns false andorganization.change_ownerstays required.This permission should be treated as a fleet-wide capability, not a narrow one. Combined with a globally held
organization.people.manageit allows assigning any organization's Owner role to any subject including the requester, and Owner assignments skip the held-permissions check by design. It is only meaningful on a global binding, creating those requiresinsider.global_roles.manage, and changes to global permission keys are recorded by the audit trigger onuser_permissions_key_value_store— but it warrants the same grant controls and review as other global capabilities.🤖 Generated with Claude Code