Skip to main content

xmtp_mls/groups/
membership.rs

1//! Adding, removing, and re-adding members.
2
3use super::*;
4
5impl<Context> MlsGroup<Context>
6where
7    Context: XmtpSharedContext,
8{
9    ///
10    /// Add members to the group by account address
11    ///
12    /// If any existing members have new installations that have not been added or removed, the
13    /// group membership will be updated to include those changes as well.
14    /// # Returns
15    /// - `Ok(UpdateGroupMembershipResult)`: Contains details about the membership changes, including:
16    ///   - `added_members`: list of added installations
17    ///   - `removed_members`: A list of installations that were removed.
18    ///   - `members_with_errors`: A list of members that encountered errors during the update.
19    /// - `Err(GroupError)`: If the operation fails due to an error.
20    #[tracing::instrument(level = "trace", skip_all)]
21    pub async fn add_members_by_identity(
22        &self,
23        account_identifiers: &[Identifier],
24    ) -> Result<UpdateGroupMembershipResult, GroupError> {
25        // Fetch the associated inbox_ids
26        let requests = account_identifiers.iter().map(Into::into).collect();
27        let inbox_id_map: HashMap<Identifier, String> = self
28            .context
29            .api()
30            .get_inbox_ids(requests)
31            .await?
32            .into_iter()
33            .zip(account_identifiers.iter().cloned())
34            .filter_map(|(inbox, identifier)| inbox.map(|inbox| (identifier, inbox)))
35            .collect();
36
37        // CFG-066 is enforced in `add_members`, the one method both entry
38        // points reach.
39        if inbox_id_map.len() != account_identifiers.len() {
40            let found_addresses: HashSet<&Identifier> = inbox_id_map.keys().collect();
41            let to_add_hashset = HashSet::from_iter(account_identifiers.iter());
42
43            let missing_addresses = found_addresses.difference(&to_add_hashset);
44            return Err(GroupError::AddressNotFound(
45                missing_addresses
46                    .into_iter()
47                    .map(|ident| format!("{ident}"))
48                    .collect(),
49            ));
50        }
51
52        self.add_members(&inbox_id_map.into_values().collect::<Vec<_>>())
53            .await
54    }
55
56    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", skip_all, fields(inbox_id = %self.context.inbox_id(), inbox_ids = ?inbox_ids.as_ref().iter().map(|i| i.as_ref()).collect::<Vec<_>>())))]
57    #[cfg_attr(
58        not(any(test, feature = "test-utils")),
59        tracing::instrument(level = "trace", skip_all)
60    )]
61    pub async fn add_members<S: AsIdRef>(
62        &self,
63        inbox_ids: impl AsRef<[S]>,
64    ) -> Result<UpdateGroupMembershipResult, GroupError> {
65        self.ensure_not_paused().await?;
66
67        let ids = inbox_ids
68            .as_ref()
69            .iter()
70            .map(AsIdRef::as_ref)
71            .collect::<Vec<&str>>();
72        let intent_data = self
73            .get_membership_update_intent(ids.as_slice(), &[])
74            .await?;
75
76        // TODO:nm this isn't the best test for whether the request is valid
77        // If some existing group member has an update, this will return an intent with changes
78        // when we really should return an error
79        let ok_result = Ok(UpdateGroupMembershipResult::from(intent_data.clone()));
80
81        if intent_data.is_empty() {
82            tracing::warn!("Member already added");
83            return ok_result;
84        }
85
86        // CFG-066: the deployment sets the ceiling, checked here — the one
87        // method both entry points reach — before the commit is built and
88        // before anything is published. The count comes from the group's own
89        // membership extension unioned with the inboxes this commit would add,
90        // not from `members()`: that helper skips an inbox still at sequence id
91        // zero, which is exactly where a just-created group's creator sits.
92        let max_members = self
93            .context
94            .server_configuration()
95            .configuration()
96            .mls
97            .max_group_members;
98        let existing = self.with_group_snapshot(|group| {
99            Ok(super::validated_commit::extract_group_membership(
100                group.extensions(),
101            )?)
102        })?;
103        let resulting: HashSet<&str> = existing
104            .inbox_ids()
105            .into_iter()
106            .chain(intent_data.membership_updates.keys().map(String::as_str))
107            .collect();
108        if resulting.len() > max_members {
109            return Err(GroupError::UserLimitExceeded);
110        }
111
112        let intent = QueueIntent::update_group_membership()
113            .data(intent_data)
114            .queue(self)?;
115
116        self.sync_until_intent_resolved(intent.id).await?;
117        let epoch = self.epoch().await?;
118
119        log_event!(
120            Event::AddedMembers,
121            self.context.installation_id(),
122            group_id = self.group_id,
123            members = ?ids,
124            epoch
125        );
126
127        ok_result
128    }
129
130    /// Removes members from the group by their account addresses.
131    ///
132    /// # Arguments
133    /// * `client` - The XMTP client.
134    /// * `account_addresses_to_remove` - A vector of account addresses to remove from the group.
135    ///
136    /// # Returns
137    /// A `Result` indicating success or failure of the operation.
138    pub async fn remove_members_by_identity(
139        &self,
140        account_addresses_to_remove: &[Identifier],
141    ) -> Result<(), GroupError> {
142        let account_addresses_to_remove =
143            account_addresses_to_remove.iter().map(Into::into).collect();
144
145        let inbox_id_map = self
146            .context
147            .api()
148            .get_inbox_ids(account_addresses_to_remove)
149            .await?;
150
151        let ids = inbox_id_map
152            .iter()
153            .flatten()
154            .map(AsRef::as_ref)
155            .collect::<Vec<&str>>();
156        self.remove_members(ids.as_slice()).await
157    }
158
159    /// Removes members from the group by their inbox IDs.
160    ///
161    /// # Arguments
162    /// * `client` - The XMTP client.
163    /// * `inbox_ids` - A vector of inbox IDs to remove from the group.
164    ///
165    /// # Returns
166    /// A `Result` indicating success or failure of the operation.
167    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", skip_all, fields(inbox_id = %self.context.inbox_id(), inbox_ids = ?inbox_ids)))]
168    #[cfg_attr(
169        not(any(test, feature = "test-utils")),
170        tracing::instrument(level = "trace", skip_all)
171    )]
172    pub async fn remove_members(&self, inbox_ids: &[InboxIdRef<'_>]) -> Result<(), GroupError> {
173        self.ensure_not_paused().await?;
174        let intent_data = self.get_membership_update_intent(&[], inbox_ids).await?;
175        let intent = QueueIntent::update_group_membership()
176            .data(intent_data)
177            .queue(self)?;
178
179        let _ = self.sync_until_intent_resolved(intent.id).await?;
180
181        Ok(())
182    }
183
184    /// Removes and readds installations from the MLS tree.
185    ///
186    /// The installation list should be validated beforehand - invalid installations
187    /// will simply be omitted at the time that the intent's publish data is computed.
188    ///
189    /// # Arguments
190    /// * `installations` - A vector of installations to readd.
191    ///
192    /// # Returns
193    /// A `Result` indicating success or failure of the operation.
194    #[allow(dead_code)]
195    pub(crate) async fn readd_installations(
196        &self,
197        installations: Vec<Vec<u8>>,
198    ) -> Result<(), GroupError> {
199        self.ensure_not_paused().await?;
200
201        let readd_min_version =
202            LibXMTPVersion::parse(xmtp_configuration::MIN_RECOVERY_REQUEST_VERSION)?;
203        let metadata = self.mutable_metadata()?;
204        let group_version = metadata
205            .attributes
206            .get(MetadataField::MinimumSupportedProtocolVersion.as_str());
207        let group_min_version =
208            LibXMTPVersion::parse(group_version.unwrap_or(&"0.0.0".to_string()))?;
209
210        if readd_min_version > group_min_version {
211            self.update_group_min_version(xmtp_configuration::MIN_RECOVERY_REQUEST_VERSION)
212                .await?;
213        }
214
215        let intent_data: Vec<u8> = ReaddInstallationsIntentData::new(installations.clone()).into();
216        let intent = QueueIntent::readd_installations()
217            .data(intent_data)
218            .queue(self)?;
219
220        let _ = self.sync_until_intent_resolved(intent.id).await?;
221
222        Ok(())
223    }
224
225    /// Process this group's pending self-remove requests end-to-end: remove the
226    /// members still in the group that requested removal, then clean up stale
227    /// pending-remove rows. Idempotent and a no-op when this client is not a
228    /// super-admin, so it is safe to call from both the inline message-processing
229    /// fast-path and the durable `TaskRunner` retry path.
230    pub(crate) async fn process_pending_self_removals(&self) -> Result<(), GroupError> {
231        // Both helpers early-return on an empty pending list; cleanup owns the
232        // flag-clear. Keeping the empty-check inside cleanup (rather than a
233        // separate clear here) avoids racing a concurrent LeaveRequest insert and
234        // wrongly clearing the flag.
235        self.remove_members_pending_removal().await?;
236        self.cleanup_pending_removal_list().await?;
237        Ok(())
238    }
239
240    /// Removes all members from the group who are currently in the pending removal list.
241    ///
242    /// Only admins and super admins can call this function. Validates permissions, filters
243    /// out invalid removal requests and performs batch removal of valid pending members.
244    ///
245    /// # Returns
246    /// * `Ok(())` - All valid pending members were successfully removed
247    /// * `Err(GroupError)` - Failed to retrieve metadata, validate permissions or execute removals
248    pub async fn remove_members_pending_removal(&self) -> Result<(), GroupError> {
249        let pending_removal_list = self.pending_remove_list()?;
250
251        if pending_removal_list.is_empty() {
252            tracing::debug!(
253                group_id = %self.group_id,
254                inbox_id = %self.context.inbox_id(),
255                "Group has no pending removal members"
256            );
257            return Ok(());
258        }
259
260        let is_super_admin = self.is_super_admin(self.context.inbox_id().to_string())?;
261        if !is_super_admin {
262            tracing::debug!(
263                group_id = %self.group_id,
264                inbox_id = %self.context.inbox_id(),
265                "Current inbox ID is not in admin or super admin list, skipping pending removal processing"
266            );
267            return Ok(());
268        }
269
270        // Get current group members to validate which ones actually exist
271        let members = self.members().await?;
272        let member_inbox_ids: HashSet<String> =
273            members.iter().map(|m| m.inbox_id.clone()).collect();
274
275        // Filter pending removals to only include actual group members
276        let valid_removals: Vec<&str> = pending_removal_list
277            .iter()
278            .filter(|inbox_id| member_inbox_ids.contains(*inbox_id))
279            .map(|s| s.as_str())
280            .collect();
281
282        if valid_removals.is_empty() {
283            tracing::warn!(
284                group_id = %self.group_id,
285                pending_count = pending_removal_list.len(),
286                "No valid members found in pending removal list"
287            );
288            return Ok(());
289        }
290        // Log members that are in pending list but not in group
291        let invalid_removals: Vec<&String> = pending_removal_list
292            .iter()
293            .filter(|inbox_id| !member_inbox_ids.contains(*inbox_id))
294            .collect();
295
296        if !invalid_removals.is_empty() {
297            tracing::warn!(
298                group_id = %self.group_id,
299                invalid_members = ?invalid_removals,
300                "Some members in pending removal list are not in the group"
301            );
302        }
303
304        // Remove all valid members at once
305        tracing::info!(
306            group_id = %self.group_id,
307            removing_count = valid_removals.len(),
308            members_to_remove = ?valid_removals,
309            "Removing pending members from group"
310        );
311
312        match self.remove_members(&valid_removals).await {
313            Ok(_) => {
314                tracing::info!(
315                    group_id = %self.group_id,
316                    removed_count = valid_removals.len(),
317                    removed_members = ?valid_removals,
318                    "Successfully removed all pending members from group"
319                );
320            }
321            Err(e) => {
322                tracing::error!(
323                    group_id = %self.group_id,
324                    removed_members = ?valid_removals,
325                    error = %e,
326                    "Failed to remove pending members from group"
327                );
328                return Err(e);
329            }
330        }
331
332        Ok(())
333    }
334
335    /// Removes members from the pending removal list who are no longer in the group.
336    ///
337    /// Iterates through all members in the pending removal list, checking each one to see
338    /// if they're still in the group. If a member is no longer in the group, they are
339    /// removed from the pending list. The pending list is refreshed after each removal
340    /// to ensure we're working with the most current data.
341    ///
342    /// # Returns
343    /// * `Ok(())` - Successfully processed all pending removal members
344    /// * `Err(GroupError)` - Failed to retrieve data or update the pending list
345    pub async fn cleanup_pending_removal_list(&self) -> Result<(), GroupError> {
346        tracing::debug!(
347            group_id = %self.group_id,
348            "Starting pending removal list cleanup"
349        );
350
351        // Get both lists upfront
352        let pending_removal_list = self.pending_remove_list()?;
353
354        if pending_removal_list.is_empty() {
355            tracing::debug!(
356                group_id = %self.group_id,
357                "No pending removals to clean up"
358            );
359            // Clear the pending leave request status
360            self.context
361                .db()
362                .set_group_has_pending_leave_request_status(&self.group_id, Some(false))?;
363            return Ok(());
364        }
365
366        // Get current group members
367        let current_members = self.members().await?;
368        let current_member_ids: Vec<String> = current_members
369            .iter()
370            .map(|member| member.inbox_id.clone())
371            .collect();
372
373        // Calculate removed members: users in pending list but not in current group
374        let removed_members: Vec<String> = pending_removal_list
375            .iter()
376            .filter(|pending_user| !current_member_ids.contains(pending_user))
377            .cloned()
378            .collect();
379
380        if !removed_members.is_empty() {
381            tracing::info!(
382                group_id = %self.group_id,
383                removed_count = removed_members.len(),
384                removed_members = ?removed_members,
385                "Removing members from pending removal list - they are no longer in the group"
386            );
387
388            // Remove all users who are no longer in the group from pending list
389            self.context
390                .db()
391                .delete_pending_remove_users(&self.group_id, removed_members)?;
392        }
393
394        // After cleanup, check if there are any pending removals left
395        let remaining_pending_list = self.pending_remove_list()?;
396        if remaining_pending_list.is_empty() {
397            // Clear the pending leave request status if no pending removals remain
398            self.context
399                .db()
400                .set_group_has_pending_leave_request_status(&self.group_id, Some(false))?;
401        }
402
403        tracing::info!(
404            group_id = %self.group_id,
405            remaining_pending = remaining_pending_list.len(),
406            "Finished cleaning up pending removal list"
407        );
408
409        Ok(())
410    }
411
412    pub async fn leave_group(&self) -> Result<(), GroupError> {
413        self.ensure_not_paused().await?;
414
415        // Check if user is a member
416        let is_member = self.is_member().await?;
417        if !is_member {
418            return Err(GroupLeaveValidationError::NotAGroupMember.into());
419        }
420
421        //check member size
422        let members = self.members().await?;
423
424        // check if the group has other members
425        if members.len() == 1 {
426            return Err(GroupLeaveValidationError::SingleMemberLeaveRejected.into());
427        }
428
429        // check if the conversation is not a DM
430        if self.metadata().await?.conversation_type == ConversationType::Dm {
431            return Err(GroupLeaveValidationError::DmLeaveForbidden.into());
432        }
433
434        let is_super_admin = self.is_super_admin(self.context.inbox_id().to_string())?;
435
436        // super-admin cannot leave a group; must be demoted first
437        // since SuperAdmins can't remove other SuperAdmins they need to be demoted first
438        if is_super_admin {
439            return Err(GroupLeaveValidationError::SuperAdminLeaveForbidden.into());
440        }
441
442        if !self.is_in_pending_remove(self.context.inbox_id())? {
443            let content = LeaveRequestCodec::encode(LeaveRequest {
444                authenticated_note: None,
445            })?;
446            self.send_message(
447                &encoded_content_to_bytes(content),
448                SendMessageOpts::default(),
449            )
450            .await?;
451        };
452        Ok(())
453    }
454
455    /// Checks if the current user is a member of the group.
456    /// Returns true if the user is a member, false otherwise.
457    #[tracing::instrument(level = "debug", skip(self))]
458    async fn is_member(&self) -> Result<bool, GroupError> {
459        let members = self.members().await?;
460        Ok(members
461            .iter()
462            .any(|m| m.inbox_id == self.context.inbox_id()))
463    }
464}