[codex] enforce invitation role hierarchy#673
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the organization invitation logic to enforce role-based restrictions when inviting new members. Specifically, it introduces a can_invite_as check to ensure that users cannot invite others with a role higher than their own (e.g., an Admin cannot invite an Owner). It also refactors permission checks into a helper method and adds corresponding unit tests. The review feedback suggests using the existing Self::map_repository_error helper function to map repository errors consistently instead of manually formatting them as internal errors.
Evrard-Nil
left a comment
There was a problem hiding this comment.
Pulled the branch and reviewed end-to-end. Build clean (cargo check -p services); the new create_invitations_rejects_roles_above_requester_role test plus the 4 pre-existing invitation tests all pass locally.
Fix is correctly scoped. The privilege-escalation gap was specifically the invitation path. Adjacent role-management code is already locked down:
update_member_role_impl(line 337) — owner-only, blocks settingrole = Owner. Admins can't promote via this path.add_member_impl(line 263) — blocksrole = Ownerunconditionally and routes Owner viatransfer_ownership.remove_member_implalready prevents removing the owner.
So invitation was the only remaining hole, and can_invite_as plugs it.
can_invite_as matrix is sensible.
| Requester | Owner | Admin | Member |
|---|---|---|---|
| Owner | ✅ | ✅ | ✅ |
| Admin | ❌ | ✅ | ✅ |
| Member | ❌ | ❌ | ❌ |
Member→any is unreachable in practice because get_invitation_requester_role short-circuits on can_manage_members() before the per-row check runs — but it's defensive defense-in-depth and matches the MemberRole predicate vocabulary.
Worth flagging (non-blocking):
-
Admin→Admin is a policy choice with a sharp edge. An admin can invite another admin; once both exist,
remove_memberallows either admin to revoke the other. A compromised admin could swamp the org with peer admins or churn the admin set faster than the owner notices. Description says this is intended (matches frontend dropdown), so I'm not pushing back, just naming the tradeoff. If you'd rather "admins can only invite at-or-below-strict" you'd change theAdminarm tomatches!(role, MemberRole::Member). -
Batch semantics — partial success on authz failure. When an admin sends
[Owner, Admin, Member], the Owner row fails and the Admin+Member rows succeed. Consistent with how "already a member" and email-send failures are handled in the same loop, but it does mean a malicious batch isn't a hard stop. Probably fine because the unauthorized rows are still rejected, just thought I'd note the choice. -
Adjacent swallowed-error pattern.
add_member_impl(line 245) andremove_member_impl(line 302) still useif let Ok(Some(member)) = …which masks DB errors as "User is not a member of this organization" — the same antipattern gemini flagged here that you fixed inget_invitation_requester_roleviamap_repository_error. Out of scope for this PR, but worth a follow-up sweep so DB blips don't surface as Unauthorized for adds/removes. -
Test gap (tiny).
make_service_with_requester_roleis only exercised forMemberRole::Admin. An explicit Member test (callingcreate_invitationsand asserting Unauthorized) would lock in the helper'scan_manage_membersgate, but the existingcan_manage_memberstest elsewhere implicitly covers it.
LGTM. Approving — the security fix is solid, the iteration after gemini's review properly switched to map_repository_error, and the test exercises the exact reported attack (admin self-elevating via invite-as-owner).
Summary
Fixes #438.
This enforces the organization role hierarchy when creating email invitations. The backend now resolves the requester's effective organization role once and rejects invitation roles above that role before writing invitation records.
Root Cause
create_invitations_implonly checked whether a requester could manage members. Since admins satisfy that check, an admin could invite another user asownerby calling the API directly, bypassing the frontend role dropdown filtering.Impact
Owners can invite owners, admins, and members. Admins can invite admins and members. Members remain unable to invite.
Validation
cargo fmtcargo test -p services create_invitationsgit diff --check