Skip to content

fix(rbac): let global owner managers pass the change_owner gate - #1184

Draft
skipi wants to merge 4 commits into
mainfrom
mk/insider-owners-manage
Draft

skipi wants to merge 4 commits into
mainfrom
mk/insider-owners-manage

Conversation

@skipi

@skipi skipi commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

assign_role requires organization.change_owner inside 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 carry organization.change_owner — even when the 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, returning PERMISSION_DENIED / "User unauthorized".

Change

The owner 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 organization id, exactly as authorize!/3 already 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, since Queries.user_to_subject_bindings_query/1 expands a user to their group subjects and permissions are stored under the binding's own org_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!/4 still 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:

  • The owner gate's own condition is unchanged. It receives the precomputed predicate rather than recomputing it, which removes a duplicate get_role_by_id and avoids computing it at all on the project-initialization path. Notably the gate is not scoped to project_id == "", so assigning a project role to a current Owner still requires the same authority it does today.
  • Because the skip is scoped to org-level assignments, a project-scope role literally named "Owner" now runs the held-permissions check where it previously skipped it. owner_role?/1 matches 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

  • Owner promotion works for a requester holding insider.owners.manage on a real global role that does not carry organization.change_owner.
  • Demotion of a current Owner works with the requester holding only the global role's permissions. The previous version of this test passed for the wrong reason: the fixture Member role granted a single permission where the real one grants many, hiding the failure. The fixture now grants more than one, and the test asserts the requester holds none of the target role's permissions.
  • An in-organization binding carrying insider.owners.manage does not satisfy the gate — the regression guard for anyone later reaching for org_id instead of nil_uuid().
  • A permission merely prefixed by insider.owners.manage does not satisfy the gate.
  • A project-scope role named "Owner" runs the held-permissions check, plus a positive counterpart that succeeds once the requester holds the role's permissions — which pins the negative test to the check rather than to the role's name.
  • The Owner role grants every org-scope permission: the invariant the held-permissions skip relies on. (Known related quirk, deliberately untouched: org-scope Admin carries the project-scope permission project.delete, which Owner holds via maps_to rather than directly, so the assertion compares org-scope permissions.)

Full ee/rbac suite green, mix format --check-formatted and mix credo --all clean. The two regression guards that cannot be red by construction were verified by mutation instead: changing nil_uuid() to org_id turns 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.manage is not seeded by this repository. Rbac.Migrator.seed_data/0 and Rbac.OnPrem.Init create only org_scope and project_scope, and Rbac.Repo.Permission.insert_default_permissions/0 reads only the organization and project sections of assets/permissions.yaml — so a new top-level key there would be dead config, and putting the entry under organization would 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?/1 returns false and organization.change_owner stays required.

This permission should be treated as a fleet-wide capability, not a narrow one. Combined with a globally held organization.people.manage it 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 requires insider.global_roles.manage, and changes to global permission keys are recorded by the audit trigger on user_permissions_key_value_store — but it warrants the same grant controls and review as other global capabilities.

🤖 Generated with Claude Code

`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>
@github-project-automation github-project-automation Bot moved this to Backlog in Roadmap Aug 19, 2026
@skipi
skipi marked this pull request as draft August 19, 2026 07:11
skipi and others added 3 commits August 19, 2026 11:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant