Found by independent adversarial review of PR #1737. Live on main. PR #1737 bounds what an actor may grant given their account scope; this is how an actor raises their own scope in a single self-edit, through a neighbouring endpoint that #1737 already guards on the permission dimension but not the account dimension.
Two independent routes, opposite directions.
Route 1 — join a wider group
guardSelfEscalation gates self-added groups on update:users, which the acting user already holds. It then calls guardSelfCarvedOutGrant, which checks only the carved-out money verbs of the group being joined.
Account scope is never checked. So joining any broader-scoped group is permitted, and a default deployment ships exactly one: Administrators, seeded allowed_accounts = ARRAY[*] by migrations 000024/000057.
Route 2 — leave the scoping group
guardGroupChange returns early unless addsNewGroup(prior, next). A self-edit that only removes groups is therefore not guarded at all.
Dropping the group carrying the account restriction leaves AllowedAccounts = [], which IsUnrestrictedAccess reads as all accounts.
Removing the restriction grants the restriction. That inversion is the reason this is worth fixing at the representation and not only at the guard.
Why the existing guards do not catch it
Both routes exercise the permission dimensions guard correctly. The defect is that the account dimension has no equivalent on this endpoint — the same asymmetry PR #1737 was written to close on the group-write path, still open on the membership path.
Fix
Apply an account-scope ceiling to self-membership changes, in both directions:
- Adding a group must not grant the actor accounts beyond their current scope.
- Removing a group must not widen the actors effective scope. This is the counter-intuitive half and the one an implementation will miss: the guard must compare resulting scope against prior scope, not merely check what is being added.
Note that guardGroupChanges addsNewGroup early return has to go, or the removal route stays unguarded no matter what is added inside it.
Verification
Both directions, and a control — a refusal-only test proves nothing here, since a fix refusing all membership edits would pass it:
- scoped actor joins Administrators (
allowed_accounts = [*]) -> refused
- scoped actor drops the group carrying their restriction -> refused
- actor makes a membership change that does not widen scope -> still allowed
- actor joins a group whose scope is a subset of their own -> still allowed
Mutation-verify per test, run individually: an aggregate package run misreports this because a mock panic kills the binary.
Related
Found by independent adversarial review of PR #1737. Live on
main. PR #1737 bounds what an actor may grant given their account scope; this is how an actor raises their own scope in a single self-edit, through a neighbouring endpoint that #1737 already guards on the permission dimension but not the account dimension.Two independent routes, opposite directions.
Route 1 — join a wider group
guardSelfEscalationgates self-added groups onupdate:users, which the acting user already holds. It then callsguardSelfCarvedOutGrant, which checks only the carved-out money verbs of the group being joined.Account scope is never checked. So joining any broader-scoped group is permitted, and a default deployment ships exactly one: Administrators, seeded
allowed_accounts = ARRAY[*]by migrations000024/000057.Route 2 — leave the scoping group
guardGroupChangereturns early unlessaddsNewGroup(prior, next). A self-edit that only removes groups is therefore not guarded at all.Dropping the group carrying the account restriction leaves
AllowedAccounts = [], whichIsUnrestrictedAccessreads as all accounts.Removing the restriction grants the restriction. That inversion is the reason this is worth fixing at the representation and not only at the guard.
Why the existing guards do not catch it
Both routes exercise the permission dimensions guard correctly. The defect is that the account dimension has no equivalent on this endpoint — the same asymmetry PR #1737 was written to close on the group-write path, still open on the membership path.
Fix
Apply an account-scope ceiling to self-membership changes, in both directions:
Note that
guardGroupChangesaddsNewGroupearly return has to go, or the removal route stays unguarded no matter what is added inside it.Verification
Both directions, and a control — a refusal-only test proves nothing here, since a fix refusing all membership edits would pass it:
allowed_accounts = [*]) -> refusedMutation-verify per test, run individually: an aggregate package run misreports this because a mock panic kills the binary.
Related