1#[cfg(test)]
2mod identity_tests;
3use crate::{
4 context::XmtpSharedContext,
5 groups::app_data::bootstrap_validator::BootstrapValidationError,
6 identity_updates::{
7 IdentityDependencyError, IdentityRequirement, InstallationDiff, InstallationDiffError,
8 get_installation_diff_local, require_association_state,
9 },
10};
11use openmls::{
12 extensions::Extensions,
13 group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit},
14 messages::proposals::Proposal,
15 prelude::{LeafNodeIndex, Sender},
16};
17
18use crate::traits::FromWith;
19use serde::Serialize;
20use std::collections::{HashMap, HashSet};
21use thiserror::Error;
22use xmtp_common::{retry::RetryableError, retryable, snippet::Snippet};
23use xmtp_db::local_commit_log::CommitType;
24use xmtp_db::{DbQuery, StorageError};
25#[cfg(doc)]
26use xmtp_id::associations::AssociationState;
27use xmtp_id::{InboxId, associations::MemberIdentifier};
28use xmtp_mls_common::{
29 app_data::component_source::{ComponentSourceError, read_from_app_data_dict},
30 group_metadata::{DmMembers, GroupMetadata, GroupMetadataError},
31 group_mutable_metadata::{GroupMutableMetadata, GroupMutableMetadataError},
32 libxmtp_version::{InvalidVersionFormat, LibXMTPVersion},
33};
34use xmtp_mls_validation::{
35 commit::{
36 CommitParticipant, CommitRuleError, Inbox, MembershipValidationInfo, MetadataChanges,
37 ProposalChanges, build_inbox_with_proposer, extract_commit_participant,
38 extract_committer_and_proposers, extract_group_membership, extract_readded_installations,
39 get_current_group_members, get_latest_group_membership, get_proposal_changes,
40 inbox_id_from_credential, metadata_changes_between, read_committed_metadata,
41 read_post_commit_mutable_metadata, validate_app_data_update_proposals_in_commit,
42 validate_committed_proposals, validate_identity_sequence_order, validate_membership_diff,
43 validate_one_app_data_update,
44 },
45 group_membership::GroupMembership,
46 group_permissions::{GroupMutablePermissionsError, MembershipPolicy, PolicySet},
47};
48use xmtp_proto::xmtp::mls::message_contents::{
49 GroupMembershipChanges, GroupUpdated as GroupUpdatedProto,
50 group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto},
51};
52
53#[derive(Debug, Error)]
56pub enum CommitValidationError {
57 #[error("Committed group state is invalid: {0}")]
59 InstalledState(#[source] Box<CommitValidationError>),
60 #[error(transparent)]
62 IdentityDependency(#[from] IdentityDependencyError),
63 #[error(transparent)]
64 InstallationDiff(#[from] InstallationDiffError),
65 #[error(transparent)]
66 StorageError(#[from] StorageError),
67 #[error(transparent)]
72 Bootstrap(#[from] BootstrapValidationError),
73 #[error(transparent)]
75 Rule(#[from] CommitRuleError),
76}
77
78impl crate::worker::NeedsDbReconnect for CommitValidationError {
79 fn needs_db_reconnect(&self) -> bool {
80 match self {
81 Self::InstalledState(error) => error.needs_db_reconnect(),
82 Self::IdentityDependency(error) => error.needs_db_reconnect(),
83 Self::StorageError(error) => error.db_needs_connection(),
84 _ => false,
85 }
86 }
87}
88
89impl RetryableError for CommitValidationError {
90 fn is_retryable(&self) -> bool {
91 match self {
92 CommitValidationError::IdentityDependency(error) => retryable!(error),
93 CommitValidationError::InstallationDiff(diff_error) => retryable!(diff_error),
94 CommitValidationError::Rule(error) => retryable!(error),
95 _ => false,
96 }
97 }
98}
99
100impl CommitValidationError {
101 pub(crate) fn installed_state(error: impl Into<Self>) -> Self {
103 Self::InstalledState(Box::new(error.into()))
104 }
105
106 pub(crate) fn is_safe_rejection(&self) -> bool {
110 match self {
111 Self::IdentityDependency(
112 IdentityDependencyError::MissingReference(_)
113 | IdentityDependencyError::InvalidSequence(_),
114 ) => true,
115 Self::Rule(error) => error.is_safe_rejection(),
116 Self::Bootstrap(error) => !matches!(
117 error,
118 BootstrapValidationError::ProtocolVersionTooLow(_)
119 | BootstrapValidationError::Synthesis(_)
120 ),
121 Self::InstalledState(_)
122 | Self::IdentityDependency(_)
123 | Self::InstallationDiff(_)
124 | Self::StorageError(_) => false,
125 }
126 }
127}
128
129macro_rules! from_rule_error {
132 ($($source:ty),* $(,)?) => {
133 $(impl From<$source> for CommitValidationError {
134 fn from(error: $source) -> Self {
135 Self::Rule(CommitRuleError::from(error))
136 }
137 })*
138 };
139}
140from_rule_error!(
141 InvalidVersionFormat,
142 GroupMetadataError,
143 GroupMutableMetadataError,
144 GroupMutablePermissionsError,
145 ComponentSourceError,
146);
147
148#[derive(Debug, Clone, Serialize)]
165pub struct ValidatedCommit {
166 pub actor: CommitParticipant,
168 pub proposers: Vec<CommitParticipant>,
170 pub added_inboxes: Vec<Inbox>,
171 pub removed_inboxes: Vec<Inbox>,
172 pub readded_installations: HashSet<Vec<u8>>,
173 pub metadata_changes: MetadataChanges,
174 pub metadata_component_ids: Vec<u16>,
175 pub installations_changed: bool,
176 pub permissions_changed: bool,
177 pub dm_members: Option<DmMembers<String>>,
178}
179
180impl ValidatedCommit {
181 #[cfg(test)]
183 pub async fn from_staged_commit(
184 context: &impl XmtpSharedContext,
185 staged_commit: &StagedCommit,
186 committer_leaf_index: LeafNodeIndex,
187 openmls_group: &OpenMlsGroup,
188 ) -> Result<Self, CommitValidationError> {
189 loop {
190 match Self::from_staged_commit_local(
191 context,
192 &context.db(),
193 staged_commit,
194 committer_leaf_index,
195 openmls_group,
196 u64::MAX,
197 ) {
198 Err(CommitValidationError::IdentityDependency(IdentityDependencyError::Need(
199 requirement,
200 ))) => {
201 crate::identity_updates::resolve_identity_requirement(context, &requirement)
202 .await?;
203 }
204 result => return result,
205 }
206 }
207 }
208
209 pub(crate) fn from_staged_commit_local(
214 context: &impl XmtpSharedContext,
215 conn: &impl DbQuery,
216 staged_commit: &StagedCommit,
217 committer_leaf_index: LeafNodeIndex,
218 openmls_group: &OpenMlsGroup,
219 envelope_sequence: u64,
220 ) -> Result<Self, CommitValidationError> {
221 if let Some(min_version) =
235 xmtp_mls_common::app_data::protocol_floor::committed_floor_exceeding(
236 openmls_group,
237 context.version_info().pkg_semver(),
238 )
239 {
240 return Err(CommitValidationError::Rule(
241 CommitRuleError::ProtocolVersionTooLow(min_version),
242 ));
243 }
244 validate_committed_proposals(
246 staged_commit
247 .queued_proposals()
248 .map(QueuedProposal::proposal),
249 |id| read_from_app_data_dict(id, openmls_group).is_some(),
250 )?;
251 let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
252 .map_err(CommitValidationError::installed_state)?;
253
254 let group_permissions =
255 super::group_permissions::policy_set_from_dictionary(openmls_group.extensions())
256 .map_err(|error| {
257 CommitValidationError::installed_state(CommitValidationError::Rule(
258 CommitRuleError::GroupMutablePermissions(error),
259 ))
260 })?;
261 let current_group_members = get_current_group_members(openmls_group);
262
263 let registry = super::app_data::load_component_registry(openmls_group)
266 .map_err(CommitValidationError::installed_state)?;
267 let min_version_bytes =
268 xmtp_mls_common::app_data::component_source::read_post_commit_component_bytes(
269 xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
270 openmls_group,
271 staged_commit,
272 ®istry,
273 )
274 .map_err(xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from)?;
275 let minimum_supported_protocol_version = match min_version_bytes {
276 Some(bytes) => Some(String::from_utf8(bytes).map_err(|e| {
277 CommitValidationError::Rule(CommitRuleError::GroupMutableMetadata(
278 GroupMutableMetadataError::MalformedComponent {
279 component_id: Some(
280 xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
281 ),
282 reason: format!("invalid utf-8: {e}"),
283 },
284 ))
285 })?),
286 None => None,
287 };
288 let (actor, proposers) = extract_committer_and_proposers(
292 staged_commit,
293 committer_leaf_index,
294 openmls_group,
295 &immutable_metadata,
296 &mutable_metadata,
297 )?;
298
299 validate_app_data_update_proposals_in_commit(
305 staged_commit,
306 openmls_group,
307 &immutable_metadata,
308 &mutable_metadata,
309 ®istry,
310 )?;
311
312 let ProposalChanges {
314 mut added_installations,
315 mut removed_installations,
316 mut credentials_to_verify,
317 added_inbox_proposers,
318 removed_inbox_proposers,
319 } = get_proposal_changes(
320 staged_commit,
321 openmls_group,
322 &immutable_metadata,
323 &mutable_metadata,
324 )?;
325
326 let expected_diff = ExpectedDiff::from_staged_commit_with_proposers(
330 conn,
331 staged_commit,
332 openmls_group,
333 envelope_sequence,
334 &added_inbox_proposers,
335 &removed_inbox_proposers,
336 )?;
337
338 let ExpectedDiff {
339 old_group_membership,
340 new_group_membership,
341 expected_installation_diff,
342 added_inboxes,
343 removed_inboxes,
344 } = expected_diff;
345
346 let installations_changed =
347 !added_installations.is_empty() || !removed_installations.is_empty();
348
349 let mut failed_installations: HashSet<Vec<u8>> = new_group_membership
350 .failed_installations
351 .iter()
352 .cloned()
353 .collect();
354
355 failed_installations.extend(
358 old_group_membership
359 .failed_installations
360 .iter()
361 .filter(|id| !current_group_members.contains(*id))
362 .cloned(),
363 );
364
365 let readded_installations = extract_readded_installations(
367 &actor,
368 &mut added_installations,
369 &mut removed_installations,
370 &mut failed_installations,
371 );
372 expected_diff_matches_commit(
374 &expected_installation_diff,
375 added_installations,
376 removed_installations,
377 current_group_members,
378 failed_installations,
379 )?;
380 credentials_to_verify.push(actor.clone());
381
382 for participant in credentials_to_verify {
387 let inbox_id = &participant.inbox_id;
388 let sequence_id = match new_group_membership.get(inbox_id) {
389 None => {
390 return Err(CommitValidationError::Rule(
391 CommitRuleError::SubjectDoesNotExist,
392 ));
393 }
394 Some(0) if old_group_membership.get(inbox_id) == Some(&0) => {
395 let known_leaf = openmls_group.members().any(|member| {
399 member.signature_key == participant.installation_id
400 && inbox_id_from_credential(&member.credential)
401 .is_ok_and(|known_inbox| known_inbox == *inbox_id)
402 });
403 if !known_leaf {
404 return Err(CommitValidationError::Rule(
405 CommitRuleError::InboxValidationFailed(inbox_id.clone()),
406 ));
407 }
408 continue;
409 }
410 Some(sequence_id) => *sequence_id,
411 };
412 let inbox_state = require_association_state(
413 conn,
414 &IdentityRequirement {
415 inbox_id: inbox_id.clone(),
416 sequence_id,
417 },
418 )?;
419
420 if inbox_state
421 .get(&MemberIdentifier::installation(participant.installation_id))
422 .is_none()
423 {
424 return Err(CommitValidationError::Rule(
425 CommitRuleError::InboxValidationFailed(participant.inbox_id),
426 ));
427 }
428 }
429
430 let membership = MembershipValidationInfo {
431 actor: &actor,
432 added_inboxes: &added_inboxes,
433 removed_inboxes: &removed_inboxes,
434 dm_members: immutable_metadata.dm_members.as_ref(),
435 };
436 if !group_permissions.policies.evaluate_membership(&membership) {
437 return Err(CommitValidationError::Rule(
438 CommitRuleError::InsufficientPermissions,
439 ));
440 }
441 if let Some(min_version) = &minimum_supported_protocol_version {
442 let current_version = context.version_info().pkg_semver();
443 let min_supported_version = LibXMTPVersion::parse(min_version)?;
444 tracing::info!(
445 "Validating commit with min_supported_version: {:?}, current_version: {:?}",
446 min_supported_version,
447 current_version
448 );
449
450 if min_supported_version > *current_version {
451 return Err(CommitValidationError::Rule(
452 CommitRuleError::ProtocolVersionTooLow(min_version.clone()),
453 ));
454 }
455 }
456 let post_metadata =
460 read_post_commit_mutable_metadata(openmls_group, staged_commit, ®istry)?;
461 let metadata_changes =
462 metadata_changes_between(&immutable_metadata, &mutable_metadata, &post_metadata);
463 let mut proposed_component_ids: Vec<u16> = staged_commit
464 .app_data_update_proposals()
465 .map(|queued| queued.app_data_update_proposal().component_id())
466 .collect();
467 proposed_component_ids.sort_unstable();
468 proposed_component_ids.dedup();
469 let mut metadata_component_ids = Vec::new();
470 for raw_id in proposed_component_ids {
471 let id = xmtp_mls_common::app_data::component_id::ComponentId::from(raw_id);
472 let before = read_from_app_data_dict(id, openmls_group);
473 let after =
474 xmtp_mls_common::app_data::component_source::read_post_commit_component_bytes(
475 id,
476 openmls_group,
477 staged_commit,
478 ®istry,
479 )
480 .map_err(GroupMutableMetadataError::from)?;
481 if before != after {
482 metadata_component_ids.push(raw_id);
483 }
484 }
485 let permissions_changed = metadata_component_ids.contains(
486 &xmtp_mls_common::app_data::component_id::ComponentId::COMPONENT_REGISTRY.as_u16(),
487 );
488 Ok(Self {
489 actor,
490 proposers,
491 added_inboxes,
492 removed_inboxes,
493 readded_installations,
494 metadata_changes,
495 metadata_component_ids,
496 installations_changed,
497 permissions_changed,
498 dm_members: immutable_metadata.dm_members.clone(),
499 })
500 }
501
502 pub fn debug_commit_type(&self) -> CommitType {
505 let metadata_info = &self.metadata_changes;
506 if !self.added_inboxes.is_empty()
507 || !self.removed_inboxes.is_empty()
508 || self.installations_changed
509 {
510 CommitType::UpdateGroupMembership
511 } else if self.permissions_changed {
512 CommitType::UpdatePermission
513 } else if !metadata_info.admins_added.is_empty()
514 || !metadata_info.admins_removed.is_empty()
515 || !metadata_info.super_admins_added.is_empty()
516 || !metadata_info.super_admins_removed.is_empty()
517 {
518 CommitType::UpdateAdminList
519 } else if !metadata_info.metadata_field_changes.is_empty() {
520 CommitType::MetadataUpdate
521 } else {
522 CommitType::KeyUpdate
523 }
524 }
525
526 pub fn is_empty(&self) -> bool {
527 self.added_inboxes.is_empty()
528 && self.removed_inboxes.is_empty()
529 && self.metadata_changes.is_empty()
530 }
531
532 pub fn actor_inbox_id(&self) -> InboxId {
533 self.actor.inbox_id.clone()
534 }
535
536 pub fn actor_installation_id(&self) -> Vec<u8> {
537 self.actor.installation_id.clone()
538 }
539}
540
541impl From<ValidatedCommit> for GroupMembershipChanges {
542 fn from(_commit: ValidatedCommit) -> Self {
543 GroupMembershipChanges {
546 members_added: vec![],
547 members_removed: vec![],
548 installations_added: vec![],
549 installations_removed: vec![],
550 }
551 }
552}
553
554struct ExpectedDiff {
556 old_group_membership: GroupMembership,
558 new_group_membership: GroupMembership,
560 expected_installation_diff: InstallationDiff,
562 added_inboxes: Vec<Inbox>,
563 removed_inboxes: Vec<Inbox>,
564}
565
566impl ExpectedDiff {
567 #[allow(clippy::too_many_arguments)]
570 pub(super) fn from_staged_commit_with_proposers(
571 conn: &impl DbQuery,
572 staged_commit: &StagedCommit,
573 openmls_group: &OpenMlsGroup,
574 envelope_sequence: u64,
575 added_inbox_proposers: &HashMap<String, CommitParticipant>,
576 removed_inbox_proposers: &HashMap<String, CommitParticipant>,
577 ) -> Result<Self, CommitValidationError> {
578 let extensions = openmls_group.extensions();
579 let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
580 .map_err(CommitValidationError::installed_state)?;
581
582 let expected_diff = Self::extract_expected_diff_with_proposers(
583 conn,
584 staged_commit,
585 envelope_sequence,
586 extensions,
587 &immutable_metadata,
588 &mutable_metadata,
589 added_inbox_proposers,
590 removed_inbox_proposers,
591 )?;
592
593 Ok(expected_diff)
594 }
595
596 #[allow(clippy::too_many_arguments)]
599 fn extract_expected_diff_with_proposers(
600 conn: &impl DbQuery,
601 staged_commit: &StagedCommit,
602 envelope_sequence: u64,
603 existing_group_extensions: &Extensions<GroupContext>,
604 immutable_metadata: &GroupMetadata,
605 mutable_metadata: &GroupMutableMetadata,
606 added_inbox_proposers: &HashMap<String, CommitParticipant>,
607 removed_inbox_proposers: &HashMap<String, CommitParticipant>,
608 ) -> Result<ExpectedDiff, CommitValidationError> {
609 let old_group_membership = extract_group_membership(existing_group_extensions)
610 .map_err(CommitValidationError::installed_state)?;
611 let new_group_membership = get_latest_group_membership(staged_commit)?;
612 validate_identity_sequence_order(&new_group_membership, envelope_sequence)?;
613 let membership_diff = old_group_membership.diff(&new_group_membership);
614
615 validate_membership_diff(
616 &old_group_membership,
617 &new_group_membership,
618 &membership_diff,
619 )?;
620
621 let added_inboxes = membership_diff
622 .added_inboxes
623 .iter()
624 .map(|inbox_id| {
625 let proposer = added_inbox_proposers
626 .get(inbox_id.as_str())
627 .cloned()
628 .ok_or(CommitValidationError::Rule(
629 CommitRuleError::ProposerNotFound,
630 ))?;
631 Ok(build_inbox_with_proposer(
632 inbox_id,
633 immutable_metadata,
634 mutable_metadata,
635 proposer,
636 ))
637 })
638 .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
639
640 let removed_inboxes = membership_diff
641 .removed_inboxes
642 .iter()
643 .map(|inbox_id| {
644 let proposer = removed_inbox_proposers
645 .get(inbox_id.as_str())
646 .cloned()
647 .ok_or(CommitValidationError::Rule(
648 CommitRuleError::ProposerNotFound,
649 ))?;
650 Ok(build_inbox_with_proposer(
651 inbox_id,
652 immutable_metadata,
653 mutable_metadata,
654 proposer,
655 ))
656 })
657 .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
658
659 let expected_installation_diff = get_installation_diff_local(
660 conn,
661 &old_group_membership,
662 &new_group_membership,
663 &membership_diff,
664 )?;
665
666 Ok(ExpectedDiff {
667 old_group_membership,
668 new_group_membership,
669 expected_installation_diff,
670 added_inboxes,
671 removed_inboxes,
672 })
673 }
674}
675
676fn expected_diff_matches_commit(
681 expected_diff: &InstallationDiff,
682 added_installations: HashSet<Vec<u8>>,
683 removed_installations: HashSet<Vec<u8>>,
684 existing_installation_ids: HashSet<Vec<u8>>,
685 failed_installation_ids: HashSet<Vec<u8>>,
686) -> Result<(), CommitValidationError> {
687 let unknown_adds = added_installations
692 .into_iter()
693 .filter(|installation_id| {
694 !expected_diff.added_installations.contains(installation_id)
695 && !existing_installation_ids.contains(installation_id)
696 })
697 .collect::<Vec<Vec<u8>>>();
698 if !unknown_adds.is_empty() {
699 return Err(CommitValidationError::Rule(
700 CommitRuleError::UnexpectedInstallationAdded(unknown_adds),
701 ));
702 }
703
704 let filtered_expected: HashSet<_> = expected_diff
705 .removed_installations
706 .iter()
707 .filter(|id| !failed_installation_ids.contains(*id))
708 .cloned()
709 .collect();
710
711 if removed_installations != filtered_expected {
712 let unexpected: Vec<_> = removed_installations
713 .difference(&expected_diff.removed_installations)
714 .cloned()
715 .collect();
716
717 return Err(CommitValidationError::Rule(
718 CommitRuleError::UnexpectedInstallationsRemoved(unexpected),
719 ));
720 }
721
722 Ok(())
723}
724
725pub fn validate_proposal(
731 proposal: &QueuedProposal,
732 openmls_group: &OpenMlsGroup,
733 policy_set: &PolicySet,
734 immutable_metadata: &GroupMetadata,
735 mutable_metadata: &GroupMutableMetadata,
736) -> Result<(), CommitValidationError> {
737 let proposer = match proposal.sender() {
739 Sender::Member(leaf_index) => extract_commit_participant(
740 leaf_index,
741 openmls_group,
742 immutable_metadata,
743 mutable_metadata,
744 )?,
745 Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
746 return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
748 }
749 };
750
751 let unsupported_error = || {
752 CommitValidationError::Rule(CommitRuleError::UnsupportedProposalType(
753 proposal.proposal().proposal_type(),
754 ))
755 };
756
757 match proposal.proposal() {
759 Proposal::Add(add_proposal) => {
760 let added_inbox_id =
762 inbox_id_from_credential(add_proposal.key_package().leaf_node().credential())?;
763 let inbox = Inbox {
764 inbox_id: added_inbox_id.clone(),
765 is_creator: false,
766 is_admin: false,
767 is_super_admin: false,
768 proposer: Some(proposer.clone()),
769 };
770 if !policy_set.add_member_policy.evaluate(&proposer, &inbox) {
771 let is_dm_add = immutable_metadata.dm_members.as_ref().is_some_and(|dm| {
773 (added_inbox_id == dm.member_one_inbox_id.as_ref()
774 || added_inbox_id == dm.member_two_inbox_id.as_ref())
775 && added_inbox_id != proposer.inbox_id
776 });
777 if !is_dm_add {
778 tracing::warn!(
779 proposer_inbox_id = %proposer.inbox_id,
780 "Proposal rejected: proposer does not have permission to add members"
781 );
782 return Err(CommitValidationError::Rule(
783 CommitRuleError::InsufficientPermissions,
784 ));
785 }
786 }
787 }
788 Proposal::Remove(remove_proposal) => {
789 let removed_member = openmls_group.member_at(remove_proposal.removed()).ok_or(
792 CommitValidationError::Rule(CommitRuleError::SubjectDoesNotExist),
793 )?;
794 let removed_inbox_id = inbox_id_from_credential(&removed_member.credential)?;
795 let removed_is_admin = mutable_metadata.admin_list.contains(&removed_inbox_id);
796 let removed_is_super_admin = mutable_metadata.is_super_admin(&removed_inbox_id);
797
798 if removed_is_super_admin {
800 tracing::warn!(
801 proposer_inbox_id = %proposer.inbox_id,
802 removed_inbox_id = %removed_inbox_id,
803 "Proposal rejected: cannot remove super admin"
804 );
805 return Err(CommitValidationError::Rule(
806 CommitRuleError::InsufficientPermissions,
807 ));
808 }
809
810 let removed_inbox = Inbox {
811 inbox_id: removed_inbox_id.clone(),
812 is_creator: immutable_metadata.creator_inbox_id == removed_inbox_id,
813 is_admin: removed_is_admin,
814 is_super_admin: removed_is_super_admin,
815 proposer: Some(proposer.clone()),
816 };
817
818 if !policy_set
819 .remove_member_policy
820 .evaluate(&proposer, &removed_inbox)
821 {
822 tracing::warn!(
823 proposer_inbox_id = %proposer.inbox_id,
824 removed_inbox_id = %removed_inbox_id,
825 "Proposal rejected: proposer does not have permission to remove members"
826 );
827 return Err(CommitValidationError::Rule(
828 CommitRuleError::InsufficientPermissions,
829 ));
830 }
831 }
832 Proposal::GroupContextExtensions(_) => return Err(unsupported_error()),
833 Proposal::Update(update_proposal) => {
834 let new_inbox_id = inbox_id_from_credential(update_proposal.leaf_node().credential())?;
837 if new_inbox_id != proposer.inbox_id {
838 tracing::warn!(
839 proposer_inbox_id = %proposer.inbox_id,
840 proposer_installation_id = %proposer.installation_id.as_slice().snippet(),
841 leaf_index = ?proposal.sender(),
842 new_inbox_id = %new_inbox_id,
843 new_installation_id = %update_proposal.leaf_node().signature_key().as_slice().snippet(),
844 "Update proposal rejected: new leaf node credential does not match proposer"
845 );
846 return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
847 }
848 }
849 Proposal::PreSharedKey(_) => {
850 return Err(unsupported_error());
851 }
852 Proposal::ReInit(_) => {
853 return Err(unsupported_error());
854 }
855 Proposal::ExternalInit(_) => {
856 return Err(unsupported_error());
857 }
858 Proposal::Custom(_) => {
859 return Err(unsupported_error());
860 }
861 Proposal::AppDataUpdate(app_data) => {
862 use super::app_data::load_component_registry;
863 use xmtp_mls_common::app_data::{
864 component_id::ComponentId, validation::ActorAuthority,
865 };
866
867 let registry = load_component_registry(openmls_group)?;
868
869 validate_one_app_data_update(
873 ComponentId::from(app_data.component_id()),
874 app_data.operation(),
875 ActorAuthority::from(&proposer),
876 &proposer.inbox_id,
877 ®istry,
878 openmls_group,
879 immutable_metadata.dm_members.as_ref(),
880 )?;
881 }
882 Proposal::AppEphemeral(_) => {
883 return Err(unsupported_error());
884 }
885 Proposal::SelfRemove => {
886 return Err(unsupported_error());
887 }
888 }
889
890 Ok(())
891}
892
893impl FromWith<ValidatedCommit> for GroupUpdatedProto {
896 type Params = Vec<String>;
898
899 fn from_with(commit: ValidatedCommit, pending_removals: &Self::Params) -> Self {
900 use std::collections::HashSet;
901
902 let pending_set: HashSet<&str> = pending_removals.iter().map(String::as_str).collect();
904
905 let (left_inboxes, removed_inboxes): (Vec<Inbox>, Vec<Inbox>) = commit
909 .removed_inboxes
910 .into_iter()
911 .partition(|inb| pending_set.contains(inb.inbox_id.as_str()));
912
913 GroupUpdatedProto {
914 initiated_by_inbox_id: commit.actor.inbox_id.clone(),
915 added_inboxes: commit.added_inboxes.iter().map(InboxProto::from).collect(),
916 removed_inboxes: removed_inboxes.iter().map(InboxProto::from).collect(),
917 metadata_field_changes: commit
918 .metadata_changes
919 .metadata_field_changes
920 .iter()
921 .map(MetadataFieldChangeProto::from)
922 .collect(),
923 left_inboxes: left_inboxes.iter().map(InboxProto::from).collect(),
924 added_admin_inboxes: commit
925 .metadata_changes
926 .admins_added
927 .iter()
928 .map(InboxProto::from)
929 .collect(),
930 removed_admin_inboxes: commit
931 .metadata_changes
932 .admins_removed
933 .iter()
934 .map(InboxProto::from)
935 .collect(),
936 added_super_admin_inboxes: commit
937 .metadata_changes
938 .super_admins_added
939 .iter()
940 .map(InboxProto::from)
941 .collect(),
942 removed_super_admin_inboxes: commit
943 .metadata_changes
944 .super_admins_removed
945 .iter()
946 .map(InboxProto::from)
947 .collect(),
948 }
949 }
950}
951
952#[cfg(test)]
953mod committed_proposal_outcomes {
954 use super::*;
955 use crate::groups::app_data::accumulate_app_data_updates;
956 use openmls::messages::proposals::{
957 AppDataUpdateOperation, AppDataUpdateProposal, ProposalType,
958 };
959 use openmls_traits::OpenMlsProvider;
960 use xmtp_mls_common::app_data::component_id::ComponentId;
961
962 #[xmtp_common::test(unwrap_try = true)]
969 async fn committed_proposal_outcomes_reject_conflicting_component_operations() {
970 use crate::tester;
971 tester!(alix, disable_workers);
972 let conversation = alix.create_group(None, None)?;
973 conversation
974 .update_group_description("described".to_string())
975 .await?;
976 let provider = alix.context.mls_provider();
977 let signer = &alix.context.identity().installation_keys;
978 let mut group = OpenMlsGroup::load(
979 alix.context.mls_storage(),
980 &conversation.group_id.to_openmls(),
981 )??;
982 let description = ComponentId::GROUP_DESCRIPTION;
983 assert!(read_from_app_data_dict(description, &group).is_some());
984
985 let update = || AppDataUpdateOperation::Update(b"changed".to_vec().into());
988 for (by_reference, inline) in [
989 (
990 update(),
991 AppDataUpdateProposal::remove(description.as_u16()),
992 ),
993 (
994 AppDataUpdateOperation::Remove,
995 AppDataUpdateProposal::update(description.as_u16(), b"changed".to_vec()),
996 ),
997 (
998 AppDataUpdateOperation::Remove,
999 AppDataUpdateProposal::remove(description.as_u16()),
1000 ),
1001 ] {
1002 group.propose_app_data_update(&provider, signer, description.as_u16(), by_reference)?;
1003 let inline = Proposal::AppDataUpdate(Box::new(inline));
1004 let pending: Vec<_> = group
1005 .pending_proposals()
1006 .map(QueuedProposal::proposal)
1007 .chain([&inline])
1008 .filter_map(|proposal| match proposal {
1009 Proposal::AppDataUpdate(p) => Some((p.component_id(), p.operation().clone())),
1010 _ => None,
1011 })
1012 .collect();
1013 let updates =
1014 accumulate_app_data_updates(&group, pending.iter().map(|(id, op)| (*id, op)))?;
1015 let mut stage = group
1016 .commit_builder()
1017 .consume_proposal_store(true)
1018 .add_proposal(inline)
1019 .load_psks(provider.storage())?;
1020 stage.with_app_data_dictionary_updates(updates);
1021 stage
1022 .build(provider.rand(), provider.crypto(), signer, |_| true)?
1023 .stage_commit(&provider)?;
1024 let staged = group.pending_commit()?;
1025 let kinds: Vec<_> = staged
1026 .queued_proposals()
1027 .map(|queued| queued.proposal().proposal_type())
1028 .collect();
1029 assert_eq!(kinds, [ProposalType::AppDataUpdate; 2]);
1030
1031 let result = ValidatedCommit::from_staged_commit_local(
1032 &alix.context,
1033 &alix.context.db(),
1034 staged,
1035 group.own_leaf_index(),
1036 &group,
1037 u64::MAX,
1038 );
1039 let Err(error) = result else {
1040 panic!("conflicting component operations were accepted");
1041 };
1042 assert!(
1043 matches!(
1044 &error,
1045 CommitValidationError::Rule(CommitRuleError::InvalidAppDataUpdateList(id))
1046 if *id == description
1047 ),
1048 "{error:?}"
1049 );
1050 assert!(error.is_safe_rejection());
1051
1052 group.clear_pending_commit(provider.storage())?;
1053 group.clear_pending_proposals(provider.storage())?;
1054 }
1055 }
1056
1057 #[xmtp_common::test(unwrap_try = true)]
1062 async fn committed_proposal_outcomes_hold_gce_proposal_and_commit() {
1063 use crate::tester;
1064 tester!(alix, disable_workers);
1065 let conversation = alix.create_group(None, None)?;
1066 let provider = alix.context.mls_provider();
1067 let mut group = OpenMlsGroup::load(
1068 alix.context.mls_storage(),
1069 &conversation.group_id.to_openmls(),
1070 )??;
1071 let signer = &alix.context.identity().installation_keys;
1072 group.propose_group_context_extensions(&provider, group.extensions().clone(), signer)?;
1073 let (immutable, mutable) = read_committed_metadata(&group)?;
1074 let proposal = group.pending_proposals().next()?;
1075 assert!(matches!(
1076 validate_proposal(
1077 proposal,
1078 &group,
1079 &PolicySet::default(),
1080 &immutable,
1081 &mutable
1082 ),
1083 Err(CommitValidationError::Rule(
1084 CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1085 ))
1086 ));
1087 group.commit_to_pending_proposals(&provider, signer)?;
1088 let staged = group.pending_commit()?;
1089 assert!(matches!(
1090 ValidatedCommit::from_staged_commit_local(
1091 &alix.context,
1092 &alix.context.db(),
1093 staged,
1094 group.own_leaf_index(),
1095 &group,
1096 u64::MAX,
1097 ),
1098 Err(CommitValidationError::Rule(
1099 CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1100 ))
1101 ));
1102 }
1103}
1104
1105#[cfg(all(test, not(target_arch = "wasm32")))]
1106mod membership_log_tests {
1107 use std::sync::Arc;
1108
1109 use xmtp_logging::{Level, LogRecord, LogSinkTarget, SinkError, test_logging::LogCapture};
1110 use xmtp_mls_validation::{
1111 commit::{CommitParticipant, Inbox, MembershipValidationInfo},
1112 group_permissions::{MembershipPolicies, PolicySet},
1113 };
1114
1115 #[derive(Default)]
1116 struct Capture(parking_lot::Mutex<Vec<LogRecord>>);
1117
1118 impl LogSinkTarget for Capture {
1119 fn on_record(&self, record: LogRecord) -> Result<(), SinkError> {
1120 self.0.lock().push(record);
1121 Ok(())
1122 }
1123 }
1124
1125 #[rstest::rstest]
1126 #[case::add_committer(false, false)]
1127 #[case::add_proposer(false, true)]
1128 #[case::remove_committer(true, false)]
1129 #[case::remove_proposer(true, true)]
1130 #[xmtp_common::test(unwrap_try = true)]
1131 fn membership_rejection_log_omits_full_ids(#[case] remove: bool, #[case] has_proposer: bool) {
1132 let actor_id = (32_u8..64).collect::<Vec<_>>();
1133 let proposer_id = (64_u8..96).collect::<Vec<_>>();
1134 let actor = CommitParticipant {
1135 inbox_id: "sentinel-actor-inbox".to_string(),
1136 installation_id: actor_id.clone(),
1137 is_creator: false,
1138 is_admin: true,
1139 is_super_admin: false,
1140 };
1141 let proposer = CommitParticipant {
1142 inbox_id: "sentinel-proposer-inbox".to_string(),
1143 installation_id: proposer_id.clone(),
1144 is_creator: true,
1145 is_admin: false,
1146 is_super_admin: true,
1147 };
1148 let changes = [Inbox {
1149 inbox_id: "sentinel-subject-inbox".to_string(),
1150 is_creator: false,
1151 is_admin: true,
1152 is_super_admin: false,
1153 proposer: has_proposer.then_some(proposer),
1154 }];
1155 let policies = PolicySet {
1156 add_member_policy: MembershipPolicies::deny(),
1157 remove_member_policy: MembershipPolicies::deny(),
1158 ..PolicySet::default()
1159 };
1160 let membership = MembershipValidationInfo {
1161 actor: &actor,
1162 added_inboxes: if remove { &[] } else { &changes },
1163 removed_inboxes: if remove { &changes } else { &[] },
1164 dm_members: None,
1165 };
1166 let sink = Arc::new(Capture::default());
1167 let capture = LogCapture::with_sink(Level::Info, Some(sink.clone()));
1168 let accepted = tracing::dispatcher::with_default(&capture.dispatch(), || {
1169 policies.evaluate_membership(&membership)
1170 });
1171 assert!(!accepted);
1172 let json = capture.output();
1173 let records = sink.0.lock();
1174 assert_eq!(records.len(), 1);
1175 for sensitive in [
1176 hex::encode(&actor_id),
1177 hex::encode(&proposer_id),
1178 format!("{actor_id:?}"),
1179 format!("{proposer_id:?}"),
1180 actor.inbox_id.clone(),
1181 "sentinel-proposer-inbox".to_string(),
1182 changes[0].inbox_id.clone(),
1183 ] {
1184 assert!(
1185 !json.contains(&sensitive),
1186 "sensitive value reached JSON log"
1187 );
1188 assert!(
1189 !records[0].message.contains(&sensitive),
1190 "sensitive value reached sink message"
1191 );
1192 assert!(
1193 records[0]
1194 .fields
1195 .values()
1196 .all(|value| !value.contains(&sensitive)),
1197 "sensitive value reached sink fields"
1198 );
1199 }
1200 let json_record: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
1201 assert_eq!(json_record["message"], "Membership policy rejected change");
1202 assert_eq!(records[0].message, "Membership policy rejected change");
1203 for (field, value) in [
1204 ("has_proposer", has_proposer),
1205 ("actor_is_creator", has_proposer),
1206 ("actor_is_admin", !has_proposer),
1207 ("actor_is_super_admin", has_proposer),
1208 ("subject_is_creator", false),
1209 ("subject_is_admin", true),
1210 ("subject_is_super_admin", false),
1211 ] {
1212 assert_eq!(json_record[field], value);
1213 assert_eq!(records[0].fields.get(field), Some(&value.to_string()));
1214 }
1215 assert_eq!(actor.installation_id, actor_id);
1216 if let Some(proposer) = &changes[0].proposer {
1217 assert_eq!(proposer.installation_id, proposer_id);
1218 }
1219 }
1220}