Skip to content

sec(auth): account scope can be laundered through the group-membership endpoint (join a wider group, or leave the scoping one) #1756

Description

@cristim

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

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions