From ba6f5386c8f63b62f398925fa440d089e780d128 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:56:23 +0000 Subject: [PATCH] core: allow moderators to change member <-> observer roles in groups (#7279) --- src/Simplex/Chat/Library/Commands.hs | 3 +++ src/Simplex/Chat/Library/Internal.hs | 8 ++++++++ src/Simplex/Chat/Library/Subscriber.hs | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 75f875de20..2f38e716ec 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2884,6 +2884,9 @@ processChatCommand cxt nm = \case when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ throwCmdError "can't change role of multiple members when admins selected, or new role is admin" when anyPending $ throwCmdError "can't change role of members pending approval" + -- TODO allow moderators (recipients already accept it; needs UI too): the observer..member limit is an + -- TODO `all` over targets - maxRole can't express it, a max hides targets below GRObserver (relay, + -- TODO unknown) that receivers reject. Fold roleRequiredToChange per target, or add allModeratable. assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) -- in relay groups the roster has a single signer, so only the owner may change member/moderator/admin roles when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $ diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 28ce796469..8f0d411c5a 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1291,6 +1291,14 @@ isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin isPrivilegedRole :: GroupMemberRole -> Bool isPrivilegedRole r = r >= GRMember +-- Minimum role allowed to change a member's role from `from` to `to` (moderators only within observer..member). +roleRequiredToChange :: GroupMemberRole -> GroupMemberRole -> GroupMemberRole +roleRequiredToChange from to + | moderatable from && moderatable to = GRModerator + | otherwise = maximum ([GRAdmin, from, to] :: [GroupMemberRole]) + where + moderatable r = GRObserver <= r && r <= GRMember + -- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. -- Runs on the parsed roster blob. validateGroupRoster :: [RosterMember] -> [RosterMember] diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 056c9fac8d..4e03d03958 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3363,7 +3363,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member); -- the delivery scope (relay forwarding) is computed on the pre-change role changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent createItem - | senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) = + | senderRole < roleRequiredToChange fromRole memRole = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing