Files
pad/docs
xarmian 59772ffc34 fix(members): a collection-restricted owner cannot grant beyond its scope through invitations or role changes (BUG-3634) (#2116)
* fix(members): a collection-restricted owner cannot grant beyond its scope through invitations or role changes (BUG-3634)

A workspace owner restricted to specific collections could invite a
second account (or any address). Accepting makes a member with
collection_access 'all', who could read and write the hidden
collections and then lift the first account's restriction. Promoting an
unrestricted viewer gave write access over collections the owner could
not see.

- Invite: a restricted caller gets 403, since an invitation carries no
  scope (requireCallerCanSetCollectionAccess with "all").
- Role change: a restricted caller may change the role only of a member
  whose scope lies within its own, by the same helper.
- Accept: AcceptWorkspaceInvitation re-reads the inviter's CURRENT scope
  in its transaction, so an invitation sent before this fix, or before
  the inviter was restricted, also grants nothing. It is refused, not
  narrowed: there is no scope to narrow to, and a silent downgrade would
  leave access nobody chose. All three doors answer
  inviter_scope_changed: the two accept routes with 403;
  register-with-code with 403 and the account rolled back when the code
  was the admission, or 201 with invitation_not_joined when
  registration is open. The invitation stays pending.

The app-install door (bot membership with 'all') is the same class and
is BUG-3768.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* docs(members): why an admin inviter is exempt from the accept-time scope check (BUG-3634, codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(members): the accept share-locks the inviter's membership while it reads its scope (BUG-3634, codex r2)

Round 2: requireInviterScopeTx read the inviter's collection_access
without a lock, so a restriction committing alongside an accept could
land after the read and before the membership. On Postgres the read is
now FOR SHARE, and the restriction's UPDATE waits for the accept or is
seen by it. A Postgres test holds a pending restriction and requires the
accept to wait on it and then refuse. SQLite's single writer already
orders the two.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(members): a restricted owner's role change re-checks the target's scope under the write's row lock (BUG-3634, codex r3)

Round 3: the handler checked the target's scope and then wrote the role
in a separate step, so an unrestricted owner widening the target in
between let the role apply across collections the caller cannot see.
UpdateWorkspaceMemberRoleWithin carries the checked set into the
transaction. At the point where the UPDATE locks the row anyway (FOR
UPDATE on Postgres, so the lock order is unchanged), it requires the
target to be still restricted to a subset of that set, or refuses with
ErrMemberScopeChanged (409 member_scope_changed). Unrestricted callers
pass nil and check nothing. Each of the two guards was removed in turn,
and the test went red each time.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* docs(api): the role change's 409 member_scope_changed (BUG-3634, codex r4 nit)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-13 11:16:07 -04:00
..