Skip to main content

xmtp_mls_common/app_data/
validation.rs

1use std::collections::HashSet;
2
3use tls_codec::Deserialize;
4use xmtp_proto::xmtp::mls::message_contents::ComponentType;
5use xmtp_proto::xmtp::mls::message_contents::MetadataPolicy as MetadataPolicyProto;
6use xmtp_proto::xmtp::mls::message_contents::metadata_policy::{
7    Kind as MetadataPolicyKind, MetadataBasePolicy,
8};
9
10use super::component_id::ComponentId;
11use super::component_registry::{ComponentOp, ComponentRegistry, ComponentRegistryError};
12use super::component_source::component_type;
13use crate::inbox_id::InboxId;
14
15/// The minimal subset of actor authority needed to evaluate base policies.
16///
17/// Carries only the booleans the policy evaluator inspects (admin and
18/// super-admin status). This crate intentionally does not depend on the
19/// richer `CommitParticipant` type from `xmtp_mls` — callers at the
20/// integration boundary construct an `ActorAuthority` from whatever actor
21/// representation they have.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct ActorAuthority {
24    pub is_admin: bool,
25    pub is_super_admin: bool,
26}
27
28/// A change being proposed against a component, used as the input to
29/// [`validate_component_write`].
30///
31/// Carries everything a base policy may read: the
32/// component, the element operation and key, the authenticated proposer
33/// and its roles, and the membership after the commit. It never carries
34/// the value being written into policy evaluation; `new_value` feeds only
35/// component invariants.
36///
37/// `old_value` is the whole component before this proposal (`None` when
38/// absent). Immutability reads it: an absent immutable component accepts
39/// one first scalar `Update` or collection `Insert`s.
40///
41/// Construct via the generated builder so that `old_value` and `new_value`
42/// (which share the same type) can't be accidentally swapped:
43///
44/// ```
45/// # use xmtp_mls_common::app_data::component_id::ComponentId;
46/// # use xmtp_mls_common::app_data::component_registry::ComponentOp;
47/// # use xmtp_mls_common::app_data::validation::{ActorAuthority, ComponentChange};
48/// # let actor = ActorAuthority { is_admin: false, is_super_admin: true };
49/// # let old_bytes = vec![1, 2, 3];
50/// # let new_bytes = vec![4, 5, 6];
51/// let change = ComponentChange::builder()
52///     .component_id(ComponentId::GROUP_NAME)
53///     .op(ComponentOp::Update)
54///     .actor(actor)
55///     .old_value(&old_bytes)
56///     .new_value(&new_bytes)
57///     .build();
58/// ```
59#[derive(Debug, Clone, bon::Builder)]
60pub struct ComponentChange<'a> {
61    pub component_id: ComponentId,
62    pub op: ComponentOp,
63    pub actor: ActorAuthority,
64    pub old_value: Option<&'a [u8]>,
65    pub new_value: Option<&'a [u8]>,
66    /// The authenticated proposer. `None` fails every self test closed.
67    pub proposer: Option<InboxId>,
68    /// Whether the proposer is one of the conversation's `DM_MEMBERS`.
69    #[builder(default)]
70    pub is_dm_participant: bool,
71    /// TLS encoding of the element key for a map or registry mutation;
72    /// `None` for a scalar, a set, or a whole-component Remove.
73    pub key: Option<&'a [u8]>,
74    /// Inbox ids in `GROUP_MEMBERSHIP` after the commit, or the committed
75    /// membership for a standalone proposal. `None` fails every
76    /// non-member test closed.
77    pub membership: Option<&'a HashSet<InboxId>>,
78}
79
80#[derive(Debug, thiserror::Error)]
81pub enum ComponentPermissionError {
82    #[error("immutable component {0} does not allow {1}")]
83    ImmutableViolation(ComponentId, ComponentOp),
84    #[error("component {0} requires super admin")]
85    SuperAdminRequired(ComponentId),
86    #[error("no registry entry for component {0}")]
87    NoRegistryEntry(ComponentId),
88    #[error("missing permissions in metadata for component {0}")]
89    MissingPermissions(ComponentId),
90    #[error("missing policy field for component {0} op {1}")]
91    MissingPolicyField(ComponentId, ComponentOp),
92    #[error("invalid policy for component {0} op {1}")]
93    InvalidPolicy(ComponentId, ComponentOp),
94    #[error("permission denied for component {0} op {1}")]
95    PermissionDenied(ComponentId, ComponentOp),
96    #[error("registry error: {0}")]
97    RegistryError(#[from] ComponentRegistryError),
98}
99
100/// Validate whether the change's actor is allowed to perform the proposed
101/// [`ComponentChange`].
102///
103/// Three-layer check:
104/// 1. **Immutability**: a component in an immutable range rejects every
105///    delete, and every write once it has a value. An absent
106///    immutable component accepts its first write under its policy: a
107///    scalar `Update` or collection `Insert`s. A map-element `Update`
108///    could only rewrite a key the same delta inserted, so it is refused.
109/// 2. **Hardcoded**: the component registry and the super admin list are
110///    super admin only, except that a DM participant may change
111///    application-range registry entries.
112/// 3. **Registry lookup**: All other components must have an entry in the
113///    component registry. No entry = denied (deny by default).
114// implements: META-004, PERM-011, PERM-012, PERM-014, PERM-026
115pub fn validate_component_write(
116    change: &ComponentChange<'_>,
117    registry: &ComponentRegistry,
118) -> Result<(), ComponentPermissionError> {
119    let component_id = change.component_id;
120    let op = change.op;
121
122    // Layer 1: Immutability check
123    let first_write = change.old_value.is_none()
124        && match op {
125            ComponentOp::Insert => true,
126            ComponentOp::Update => change.key.is_none(),
127            ComponentOp::Delete => false,
128        };
129    if component_id.is_immutable() && !first_write {
130        return Err(ComponentPermissionError::ImmutableViolation(
131            component_id,
132            op,
133        ));
134    }
135
136    // Layer 2: Hardcoded components — always require super admin.
137    // `is_hardcoded()` is the source of truth for which IDs land here:
138    // adding a new hardcoded component is a single-line change to that
139    // function and this branch picks it up automatically.
140    if component_id.is_hardcoded() {
141        return if change.actor.is_super_admin || is_dm_application_registry_change(change) {
142            Ok(())
143        } else {
144            Err(ComponentPermissionError::SuperAdminRequired(component_id))
145        };
146    }
147
148    // Layer 3: Registry lookup (deny by default)
149    let meta = registry
150        .get(&component_id)?
151        .ok_or(ComponentPermissionError::NoRegistryEntry(component_id))?;
152
153    // The registry's `validate_metadata` guarantees that any stored entry
154    // has `permissions: Some` and all three policy fields populated. The
155    // checks below are defensive — they should never fire in practice but
156    // we'd rather return a structured error than panic.
157    let permissions = meta
158        .permissions
159        .ok_or(ComponentPermissionError::MissingPermissions(component_id))?;
160
161    let policy_proto: MetadataPolicyProto = match op {
162        ComponentOp::Insert => permissions.insert_policy,
163        ComponentOp::Update => permissions.update_policy,
164        ComponentOp::Delete => permissions.delete_policy,
165    }
166    .ok_or(ComponentPermissionError::MissingPolicyField(
167        component_id,
168        op,
169    ))?;
170
171    // A well-known id has a fixed type; the registry types the rest.
172    let component_type = component_type(component_id).map_or(meta.component_type, |ty| ty as i32);
173    let inbox_keyed = [
174        ComponentType::TlsMapInboxIdBytes as i32,
175        ComponentType::TlsMapInboxIdString as i32,
176    ]
177    .contains(&component_type);
178    let subject = PolicySubject {
179        actor: ActorAuthority {
180            // A DM participant satisfies ALLOW_IF_SUPER_ADMIN on
181            // application components only.
182            is_super_admin: change.actor.is_super_admin
183                || (change.is_dm_participant && component_id.is_app_range()),
184            ..change.actor
185        },
186        op,
187        proposer: change.proposer,
188        inbox_key: change
189            .key
190            .filter(|_| inbox_keyed)
191            .and_then(|key| InboxId::tls_deserialize_exact(key).ok()),
192        membership: change.membership,
193    };
194    match evaluate_policy_proto(&policy_proto, &subject) {
195        PolicyOutcome::Allow => Ok(()),
196        PolicyOutcome::Deny => Err(ComponentPermissionError::PermissionDenied(component_id, op)),
197        PolicyOutcome::Invalid => Err(ComponentPermissionError::InvalidPolicy(component_id, op)),
198    }
199}
200
201/// A DM participant may insert, update, or delete a registry
202/// entry whose id is in the application range. Never `SUPER_ADMIN_LIST`,
203/// never a well-known entry, never the whole registry.
204fn is_dm_application_registry_change(change: &ComponentChange<'_>) -> bool {
205    change.is_dm_participant
206        && change.component_id == ComponentId::COMPONENT_REGISTRY
207        && change
208            .key
209            .and_then(|key| ComponentId::tls_deserialize_exact(key).ok())
210            .is_some_and(ComponentId::is_app_range)
211}
212
213/// Everything a base policy may read about one element change.
214struct PolicySubject<'a> {
215    /// The proposer's roles, with DM authority already applied.
216    actor: ActorAuthority,
217    op: ComponentOp,
218    proposer: Option<InboxId>,
219    /// The element key when the component is an inbox-keyed map.
220    inbox_key: Option<InboxId>,
221    membership: Option<&'a HashSet<InboxId>>,
222}
223
224/// Result of evaluating a [`MetadataPolicyProto`] against a [`ComponentChange`].
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226enum PolicyOutcome {
227    /// The policy permits the change.
228    Allow,
229    /// The policy denies the change.
230    Deny,
231    /// The policy is malformed (unknown base policy variant, empty combinator,
232    /// missing kind, etc.).
233    Invalid,
234}
235
236impl PolicyOutcome {
237    fn from_bool(allowed: bool) -> Self {
238        if allowed { Self::Allow } else { Self::Deny }
239    }
240}
241
242/// Walk a [`MetadataPolicyProto`] and evaluate it against a
243/// [`PolicySubject`].
244///
245/// **Combinator semantics:**
246/// - `AndCondition` short-circuits on the first non-`Allow` outcome and
247///   propagates it (so `Deny` or `Invalid` wins over later siblings).
248/// - `AnyCondition` short-circuits on the first `Allow` *or* `Invalid`
249///   outcome — a single malformed sub-policy poisons the whole `OR`. The
250///   alternative ("keep scanning past `Invalid` looking for an `Allow`")
251///   would let a sender hide a structurally broken policy as long as
252///   *some* sibling allowed, which makes the broken policy invisible to
253///   peers and harder to repair. Failing closed on `Invalid` is the
254///   conservative choice.
255/// - Empty `AndCondition` / `AnyCondition` are `Invalid` rather than
256///   vacuously `Allow`/`Deny`.
257// implements: PERM-007, PERM-008
258fn evaluate_policy_proto(
259    proto: &MetadataPolicyProto,
260    subject: &PolicySubject<'_>,
261) -> PolicyOutcome {
262    match &proto.kind {
263        Some(MetadataPolicyKind::Base(base)) => evaluate_base_policy(*base, subject),
264        Some(MetadataPolicyKind::AndCondition(and)) => {
265            if and.policies.is_empty() {
266                return PolicyOutcome::Invalid;
267            }
268            for inner in &and.policies {
269                match evaluate_policy_proto(inner, subject) {
270                    PolicyOutcome::Allow => continue,
271                    other => return other,
272                }
273            }
274            PolicyOutcome::Allow
275        }
276        Some(MetadataPolicyKind::AnyCondition(any)) => {
277            if any.policies.is_empty() {
278                return PolicyOutcome::Invalid;
279            }
280            for inner in &any.policies {
281                match evaluate_policy_proto(inner, subject) {
282                    PolicyOutcome::Allow => return PolicyOutcome::Allow,
283                    PolicyOutcome::Deny => {}
284                    PolicyOutcome::Invalid => return PolicyOutcome::Invalid,
285                }
286            }
287            PolicyOutcome::Deny
288        }
289        None => PolicyOutcome::Invalid,
290    }
291}
292
293// implements: PERM-027, PERM-008
294fn evaluate_base_policy(base: i32, subject: &PolicySubject<'_>) -> PolicyOutcome {
295    let actor = subject.actor;
296    let base = match MetadataBasePolicy::try_from(base) {
297        Ok(b) => b,
298        Err(_) => return PolicyOutcome::Invalid,
299    };
300    match base {
301        MetadataBasePolicy::Allow => PolicyOutcome::Allow,
302        MetadataBasePolicy::Deny => PolicyOutcome::Deny,
303        MetadataBasePolicy::AllowIfAdmin => {
304            PolicyOutcome::from_bool(actor.is_admin || actor.is_super_admin)
305        }
306        MetadataBasePolicy::AllowIfSuperAdmin => PolicyOutcome::from_bool(actor.is_super_admin),
307        MetadataBasePolicy::AllowIfSelfOrNonMember => {
308            // Denies a non-inbox-map type and a whole-component Remove,
309            // neither of which has an inbox key.
310            let Some(key) = subject.inbox_key else {
311                return PolicyOutcome::Deny;
312            };
313            let is_self = subject.proposer == Some(key);
314            PolicyOutcome::from_bool(match subject.op {
315                ComponentOp::Insert | ComponentOp::Update => is_self,
316                ComponentOp::Delete => {
317                    is_self || subject.membership.is_some_and(|m| !m.contains(&key))
318                }
319            })
320        }
321        MetadataBasePolicy::Unspecified => PolicyOutcome::Invalid,
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::app_data::component_permissions::component_permissions;
329    use crate::app_data::component_registry::new_component_metadata;
330    use tls_codec::Serialize;
331    use xmtp_proto::xmtp::mls::message_contents::{
332        ComponentType, MetadataPolicy as MetadataPolicyProto,
333        metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
334    };
335
336    fn make_policy(base: MetadataBasePolicy) -> MetadataPolicyProto {
337        MetadataPolicyProto {
338            kind: Some(MetadataPolicyKind::Base(base as i32)),
339        }
340    }
341
342    fn allow() -> MetadataPolicyProto {
343        make_policy(MetadataBasePolicy::Allow)
344    }
345
346    fn deny() -> MetadataPolicyProto {
347        make_policy(MetadataBasePolicy::Deny)
348    }
349
350    fn admin_only() -> MetadataPolicyProto {
351        make_policy(MetadataBasePolicy::AllowIfAdmin)
352    }
353
354    fn super_admin_only() -> MetadataPolicyProto {
355        make_policy(MetadataBasePolicy::AllowIfSuperAdmin)
356    }
357
358    fn make_actor(is_admin: bool, is_super_admin: bool) -> ActorAuthority {
359        ActorAuthority {
360            is_admin,
361            is_super_admin,
362        }
363    }
364
365    /// Test helper. Constructs a [`ComponentChange`] with no values, key,
366    /// proposer, or membership. Role policies read none of these; the
367    /// self-owned policy and immutability tests set them explicitly.
368    fn change<'a>(id: ComponentId, op: ComponentOp, actor: ActorAuthority) -> ComponentChange<'a> {
369        ComponentChange::builder()
370            .component_id(id)
371            .op(op)
372            .actor(actor)
373            .build()
374    }
375
376    fn member() -> ActorAuthority {
377        make_actor(false, false)
378    }
379
380    fn admin() -> ActorAuthority {
381        make_actor(true, false)
382    }
383
384    fn super_admin() -> ActorAuthority {
385        make_actor(true, true)
386    }
387
388    fn setup_registry_with(
389        id: ComponentId,
390        insert: MetadataPolicyProto,
391        update: MetadataPolicyProto,
392        delete: MetadataPolicyProto,
393    ) -> ComponentRegistry {
394        typed_registry(id, ComponentType::Bytes, insert, update, delete)
395    }
396
397    fn typed_registry(
398        id: ComponentId,
399        ty: ComponentType,
400        insert: MetadataPolicyProto,
401        update: MetadataPolicyProto,
402        delete: MetadataPolicyProto,
403    ) -> ComponentRegistry {
404        let mut reg = ComponentRegistry::new();
405        reg.set(
406            id,
407            new_component_metadata(
408                component_permissions()
409                    .insert(insert)
410                    .update(update)
411                    .delete(delete)
412                    .call(),
413                ty,
414            ),
415        )
416        .unwrap();
417        reg
418    }
419
420    // === Immutability Tests ===
421
422    #[xmtp_common::test]
423    // verifies: META-004
424    fn test_immutable_insert_allowed() {
425        let id = ComponentId::CONVERSATION_TYPE;
426        let reg = setup_registry_with(id, allow(), deny(), deny());
427        let actor = super_admin();
428        let result = validate_component_write(&change(id, ComponentOp::Insert, actor), &reg);
429        assert!(result.is_ok());
430    }
431
432    /// An immutable component that already has a value rejects every
433    /// further write, whatever its policies allow.
434    #[xmtp_common::test]
435    // verifies: META-004
436    fn test_immutable_update_rejected() {
437        let id = ComponentId::CONVERSATION_TYPE;
438        let reg = setup_registry_with(id, allow(), allow(), allow());
439        for op in [ComponentOp::Insert, ComponentOp::Update] {
440            let written = ComponentChange::builder()
441                .component_id(id)
442                .op(op)
443                .actor(super_admin())
444                .old_value(b"dm")
445                .build();
446            assert!(matches!(
447                validate_component_write(&written, &reg),
448                Err(ComponentPermissionError::ImmutableViolation(_, got)) if got == op
449            ));
450        }
451    }
452
453    /// An absent immutable scalar accepts one initial Update, which its
454    /// registry policy then judges like any other write.
455    #[xmtp_common::test]
456    // verifies: META-004
457    fn test_immutable_first_scalar_write_uses_policy() {
458        let id = ComponentId::new(0xFD00);
459        let first = |actor| change(id, ComponentOp::Update, actor);
460        let reg = setup_registry_with(id, deny(), super_admin_only(), deny());
461        assert!(validate_component_write(&first(super_admin()), &reg).is_ok());
462        assert!(matches!(
463            validate_component_write(&first(admin()), &reg),
464            Err(ComponentPermissionError::PermissionDenied(
465                _,
466                ComponentOp::Update
467            ))
468        ));
469    }
470
471    /// An absent immutable map takes insert-only mutations: a keyed
472    /// Update could only rewrite a value the same delta just inserted.
473    // verifies: META-004
474    #[xmtp_common::test(unwrap_try = true)]
475    fn test_immutable_absent_map_rejects_element_update() {
476        let id = ComponentId::new(0xFD01);
477        let reg = typed_registry(
478            id,
479            ComponentType::TlsMapBytesBytes,
480            allow(),
481            allow(),
482            allow(),
483        );
484        let element = |op| {
485            ComponentChange::builder()
486                .component_id(id)
487                .op(op)
488                .actor(super_admin())
489                .key(b"k")
490                .build()
491        };
492        validate_component_write(&element(ComponentOp::Insert), &reg)?;
493        assert!(matches!(
494            validate_component_write(&element(ComponentOp::Update), &reg),
495            Err(ComponentPermissionError::ImmutableViolation(
496                _,
497                ComponentOp::Update
498            ))
499        ));
500    }
501
502    #[xmtp_common::test]
503    // verifies: META-004
504    fn test_immutable_delete_rejected() {
505        let id = ComponentId::CONVERSATION_TYPE;
506        let reg = setup_registry_with(id, allow(), allow(), allow());
507        let actor = super_admin();
508        let result = validate_component_write(&change(id, ComponentOp::Delete, actor), &reg);
509        assert!(matches!(
510            result,
511            Err(ComponentPermissionError::ImmutableViolation(
512                _,
513                ComponentOp::Delete
514            ))
515        ));
516    }
517
518    // === Hardcoded Tests ===
519
520    #[xmtp_common::test]
521    fn test_registry_super_admin_allowed() {
522        let reg = ComponentRegistry::new();
523        let actor = super_admin();
524        let result = validate_component_write(
525            &change(ComponentId::COMPONENT_REGISTRY, ComponentOp::Update, actor),
526            &reg,
527        );
528        assert!(result.is_ok());
529    }
530
531    #[xmtp_common::test]
532    // verifies: PERM-026
533    fn test_registry_admin_rejected() {
534        let reg = ComponentRegistry::new();
535        let actor = admin();
536        let result = validate_component_write(
537            &change(ComponentId::COMPONENT_REGISTRY, ComponentOp::Update, actor),
538            &reg,
539        );
540        assert!(matches!(
541            result,
542            Err(ComponentPermissionError::SuperAdminRequired(_))
543        ));
544    }
545
546    #[xmtp_common::test]
547    // verifies: PERM-026
548    fn test_registry_member_rejected() {
549        let reg = ComponentRegistry::new();
550        let actor = member();
551        let result = validate_component_write(
552            &change(ComponentId::COMPONENT_REGISTRY, ComponentOp::Update, actor),
553            &reg,
554        );
555        assert!(matches!(
556            result,
557            Err(ComponentPermissionError::SuperAdminRequired(_))
558        ));
559    }
560
561    #[xmtp_common::test]
562    fn test_super_admin_list_super_admin_allowed() {
563        let reg = ComponentRegistry::new();
564        let actor = super_admin();
565        let result = validate_component_write(
566            &change(ComponentId::SUPER_ADMIN_LIST, ComponentOp::Insert, actor),
567            &reg,
568        );
569        assert!(result.is_ok());
570    }
571
572    #[xmtp_common::test]
573    // verifies: PERM-026
574    fn test_super_admin_list_admin_rejected() {
575        let reg = ComponentRegistry::new();
576        let actor = admin();
577        let result = validate_component_write(
578            &change(ComponentId::SUPER_ADMIN_LIST, ComponentOp::Insert, actor),
579            &reg,
580        );
581        assert!(matches!(
582            result,
583            Err(ComponentPermissionError::SuperAdminRequired(_))
584        ));
585    }
586
587    #[xmtp_common::test]
588    fn test_admin_list_with_admin_policy_admin_allowed() {
589        let reg = setup_registry_with(
590            ComponentId::ADMIN_LIST,
591            admin_only(),
592            admin_only(),
593            admin_only(),
594        );
595        let actor = admin();
596        let result = validate_component_write(
597            &change(ComponentId::ADMIN_LIST, ComponentOp::Insert, actor),
598            &reg,
599        );
600        assert!(result.is_ok());
601    }
602
603    #[xmtp_common::test]
604    fn test_admin_list_with_admin_policy_member_rejected() {
605        let reg = setup_registry_with(
606            ComponentId::ADMIN_LIST,
607            admin_only(),
608            admin_only(),
609            admin_only(),
610        );
611        let actor = member();
612        let result = validate_component_write(
613            &change(ComponentId::ADMIN_LIST, ComponentOp::Insert, actor),
614            &reg,
615        );
616        assert!(matches!(
617            result,
618            Err(ComponentPermissionError::PermissionDenied(_, _))
619        ));
620    }
621
622    #[xmtp_common::test]
623    fn test_admin_list_with_super_admin_policy() {
624        let reg = setup_registry_with(
625            ComponentId::ADMIN_LIST,
626            super_admin_only(),
627            super_admin_only(),
628            super_admin_only(),
629        );
630        let admin_actor = admin();
631        let super_admin_actor = super_admin();
632        // Admin rejected
633        assert!(
634            validate_component_write(
635                &change(ComponentId::ADMIN_LIST, ComponentOp::Insert, admin_actor),
636                &reg,
637            )
638            .is_err()
639        );
640        // Super admin allowed
641        assert!(
642            validate_component_write(
643                &change(
644                    ComponentId::ADMIN_LIST,
645                    ComponentOp::Insert,
646                    super_admin_actor,
647                ),
648                &reg,
649            )
650            .is_ok()
651        );
652    }
653
654    // === Registry Lookup Tests ===
655
656    #[xmtp_common::test]
657    // verifies: PERM-012
658    fn test_deny_by_default_no_entry() {
659        let reg = ComponentRegistry::new();
660        let actor = super_admin();
661        let result = validate_component_write(
662            &change(ComponentId::GROUP_NAME, ComponentOp::Insert, actor),
663            &reg,
664        );
665        assert!(matches!(
666            result,
667            Err(ComponentPermissionError::NoRegistryEntry(_))
668        ));
669    }
670
671    #[xmtp_common::test]
672    // verifies: PERM-027
673    fn test_insert_allow_policy() {
674        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), deny(), deny());
675        let actor = member();
676        let result = validate_component_write(
677            &change(ComponentId::GROUP_NAME, ComponentOp::Insert, actor),
678            &reg,
679        );
680        assert!(result.is_ok());
681    }
682
683    #[xmtp_common::test]
684    // verifies: PERM-027
685    fn test_update_admin_only_policy_admin_passes() {
686        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), admin_only(), deny());
687        let actor = admin();
688        let result = validate_component_write(
689            &change(ComponentId::GROUP_NAME, ComponentOp::Update, actor),
690            &reg,
691        );
692        assert!(result.is_ok());
693    }
694
695    #[xmtp_common::test]
696    // verifies: PERM-027
697    fn test_update_admin_only_policy_member_fails() {
698        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), admin_only(), deny());
699        let actor = member();
700        let result = validate_component_write(
701            &change(ComponentId::GROUP_NAME, ComponentOp::Update, actor),
702            &reg,
703        );
704        assert!(matches!(
705            result,
706            Err(ComponentPermissionError::PermissionDenied(
707                _,
708                ComponentOp::Update
709            ))
710        ));
711    }
712
713    #[xmtp_common::test]
714    // verifies: PERM-027
715    fn test_delete_deny_policy() {
716        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), allow(), deny());
717        let actor = super_admin();
718        let result = validate_component_write(
719            &change(ComponentId::GROUP_NAME, ComponentOp::Delete, actor),
720            &reg,
721        );
722        assert!(matches!(
723            result,
724            Err(ComponentPermissionError::PermissionDenied(
725                _,
726                ComponentOp::Delete
727            ))
728        ));
729    }
730
731    #[xmtp_common::test]
732    // verifies: PERM-027
733    fn test_delete_super_admin_only_policy() {
734        let reg = setup_registry_with(
735            ComponentId::GROUP_NAME,
736            allow(),
737            allow(),
738            super_admin_only(),
739        );
740        let actor = super_admin();
741        let result = validate_component_write(
742            &change(ComponentId::GROUP_NAME, ComponentOp::Delete, actor),
743            &reg,
744        );
745        assert!(result.is_ok());
746    }
747
748    #[xmtp_common::test]
749    // verifies: PERM-011
750    fn test_different_insert_vs_update_permissions() {
751        // Mimics group membership: anyone can update, only admin can insert
752        let reg = setup_registry_with(
753            ComponentId::GROUP_MEMBERSHIP,
754            admin_only(),
755            allow(),
756            admin_only(),
757        );
758        let member_actor = member();
759        let admin_actor = admin();
760
761        // Member can update
762        assert!(
763            validate_component_write(
764                &change(
765                    ComponentId::GROUP_MEMBERSHIP,
766                    ComponentOp::Update,
767                    member_actor,
768                ),
769                &reg,
770            )
771            .is_ok()
772        );
773
774        // Member cannot insert
775        assert!(
776            validate_component_write(
777                &change(
778                    ComponentId::GROUP_MEMBERSHIP,
779                    ComponentOp::Insert,
780                    member_actor,
781                ),
782                &reg,
783            )
784            .is_err()
785        );
786
787        // Admin can insert
788        assert!(
789            validate_component_write(
790                &change(
791                    ComponentId::GROUP_MEMBERSHIP,
792                    ComponentOp::Insert,
793                    admin_actor,
794                ),
795                &reg,
796            )
797            .is_ok()
798        );
799    }
800
801    #[xmtp_common::test]
802    // verifies: PERM-014
803    fn test_app_range_component() {
804        let app_id = ComponentId::new(0xC100);
805        let reg = setup_registry_with(app_id, allow(), allow(), deny());
806        let actor = member();
807        let result = validate_component_write(&change(app_id, ComponentOp::Insert, actor), &reg);
808        assert!(result.is_ok());
809    }
810
811    // === Self-owned policy ===
812
813    const PROFILE: ComponentId = ComponentId::new(0xC100);
814
815    fn inbox(byte: u8) -> InboxId {
816        InboxId::from_bytes([byte; 32])
817    }
818
819    fn self_owned() -> MetadataPolicyProto {
820        make_policy(MetadataBasePolicy::AllowIfSelfOrNonMember)
821    }
822
823    fn self_owned_registry(id: ComponentId, ty: ComponentType) -> ComponentRegistry {
824        typed_registry(id, ty, self_owned(), self_owned(), self_owned())
825    }
826
827    /// Validate `op` on `key` in `id` by `proposer`, a plain member, with
828    /// `membership` after the commit.
829    fn write_key(
830        reg: &ComponentRegistry,
831        id: ComponentId,
832        op: ComponentOp,
833        proposer: InboxId,
834        key: Option<InboxId>,
835        membership: &HashSet<InboxId>,
836    ) -> Result<(), ComponentPermissionError> {
837        let key = key.map(|key| key.tls_serialize_detached().unwrap());
838        let change = ComponentChange::builder()
839            .component_id(id)
840            .op(op)
841            .actor(member())
842            .proposer(proposer)
843            .maybe_key(key.as_deref())
844            .membership(membership)
845            .build();
846        validate_component_write(&change, reg)
847    }
848
849    /// A member may write its own entry of an inbox-keyed map, and no
850    /// member may write another's, so a display name cannot be forged.
851    #[xmtp_common::test(unwrap_try = true)]
852    // verifies: PERM-027
853    fn self_owned_writes_only_own_key() {
854        let (alice, bob) = (inbox(1), inbox(2));
855        let members = HashSet::from([alice, bob]);
856        for ty in [
857            ComponentType::TlsMapInboxIdBytes,
858            ComponentType::TlsMapInboxIdString,
859        ] {
860            let reg = self_owned_registry(PROFILE, ty);
861            for op in [
862                ComponentOp::Insert,
863                ComponentOp::Update,
864                ComponentOp::Delete,
865            ] {
866                write_key(&reg, PROFILE, op, alice, Some(alice), &members)?;
867                assert!(matches!(
868                    write_key(&reg, PROFILE, op, alice, Some(bob), &members),
869                    Err(ComponentPermissionError::PermissionDenied(_, got)) if got == op
870                ));
871            }
872        }
873    }
874
875    /// Any member may delete the entry of an inbox that is not a member
876    /// after the commit, so a departed member's data can be cleaned up,
877    /// but may not insert or update it.
878    #[xmtp_common::test(unwrap_try = true)]
879    // verifies: PERM-027
880    fn self_owned_deletes_non_member_key() {
881        let (alice, gone) = (inbox(1), inbox(3));
882        let members = HashSet::from([alice]);
883        let reg = self_owned_registry(PROFILE, ComponentType::TlsMapInboxIdString);
884        write_key(
885            &reg,
886            PROFILE,
887            ComponentOp::Delete,
888            alice,
889            Some(gone),
890            &members,
891        )?;
892        for op in [ComponentOp::Insert, ComponentOp::Update] {
893            assert!(write_key(&reg, PROFILE, op, alice, Some(gone), &members).is_err());
894        }
895    }
896
897    /// The policy fails closed without an inbox key: a whole-component
898    /// Remove, a component that is not an inbox-keyed map, a key that is
899    /// not an inbox id, or a missing proposer or membership.
900    #[xmtp_common::test(unwrap_try = true)]
901    // verifies: PERM-027
902    fn self_owned_fails_closed_without_context() {
903        let (alice, gone) = (inbox(1), inbox(3));
904        let members = HashSet::from([alice]);
905        let map = self_owned_registry(PROFILE, ComponentType::TlsMapInboxIdString);
906        assert!(write_key(&map, PROFILE, ComponentOp::Delete, alice, None, &members).is_err());
907
908        for ty in [
909            ComponentType::TlsMapBytesBytes,
910            ComponentType::TlsSetInboxId,
911        ] {
912            let reg = self_owned_registry(PROFILE, ty);
913            assert!(
914                write_key(
915                    &reg,
916                    PROFILE,
917                    ComponentOp::Insert,
918                    alice,
919                    Some(alice),
920                    &members
921                )
922                .is_err()
923            );
924        }
925
926        // A well-known id keeps its fixed type, not its registry entry's.
927        let forged =
928            self_owned_registry(ComponentId::GROUP_NAME, ComponentType::TlsMapInboxIdBytes);
929        assert!(
930            write_key(
931                &forged,
932                ComponentId::GROUP_NAME,
933                ComponentOp::Update,
934                alice,
935                Some(alice),
936                &members
937            )
938            .is_err()
939        );
940
941        let short_key = ComponentChange::builder()
942            .component_id(PROFILE)
943            .op(ComponentOp::Insert)
944            .actor(member())
945            .proposer(alice)
946            .key(&[0u8; 4])
947            .membership(&members)
948            .build();
949        assert!(validate_component_write(&short_key, &map).is_err());
950
951        // Without a proposer no key is its own; without a membership no
952        // key is a non-member's.
953        let own_key = alice.tls_serialize_detached()?;
954        let no_proposer = ComponentChange::builder()
955            .component_id(PROFILE)
956            .op(ComponentOp::Delete)
957            .actor(super_admin())
958            .key(&own_key)
959            .membership(&members)
960            .build();
961        assert!(validate_component_write(&no_proposer, &map).is_err());
962        let gone_key = gone.tls_serialize_detached()?;
963        let no_membership = ComponentChange {
964            proposer: Some(alice),
965            key: Some(&gone_key),
966            membership: None,
967            ..no_proposer
968        };
969        assert!(validate_component_write(&no_membership, &map).is_err());
970    }
971
972    // === DM authority ===
973
974    fn dm_change<'a>(
975        id: ComponentId,
976        op: ComponentOp,
977        key: Option<&'a [u8]>,
978    ) -> ComponentChange<'a> {
979        ComponentChange::builder()
980            .component_id(id)
981            .op(op)
982            .actor(member())
983            .is_dm_participant(true)
984            .maybe_key(key)
985            .build()
986    }
987
988    /// A DM participant may change a registry entry whose key is an
989    /// application id, but not a well-known entry, not the super-admin
990    /// list, and not the whole registry.
991    #[xmtp_common::test(unwrap_try = true)]
992    // verifies: PERM-026
993    fn dm_participant_changes_only_application_registry_entries() {
994        let reg = ComponentRegistry::new();
995        let registry = ComponentId::COMPONENT_REGISTRY;
996        for id in [0xC000, 0xFEFF] {
997            let key = ComponentId::new(id).tls_serialize_detached()?;
998            for op in [
999                ComponentOp::Insert,
1000                ComponentOp::Update,
1001                ComponentOp::Delete,
1002            ] {
1003                validate_component_write(&dm_change(registry, op, Some(&key)), &reg)?;
1004            }
1005        }
1006        for id in [
1007            ComponentId::GROUP_NAME,
1008            ComponentId::new(0xBFFF),
1009            ComponentId::new(0xFF00),
1010        ] {
1011            let key = id.tls_serialize_detached()?;
1012            assert!(matches!(
1013                validate_component_write(
1014                    &dm_change(registry, ComponentOp::Insert, Some(&key)),
1015                    &reg
1016                ),
1017                Err(ComponentPermissionError::SuperAdminRequired(_))
1018            ));
1019        }
1020        assert!(
1021            validate_component_write(&dm_change(registry, ComponentOp::Delete, None), &reg)
1022                .is_err()
1023        );
1024        let app_key = ComponentId::new(0xC000).tls_serialize_detached()?;
1025        assert!(
1026            validate_component_write(
1027                &dm_change(
1028                    ComponentId::SUPER_ADMIN_LIST,
1029                    ComponentOp::Insert,
1030                    Some(&app_key)
1031                ),
1032                &reg
1033            )
1034            .is_err()
1035        );
1036        let outsider = ComponentChange {
1037            is_dm_participant: false,
1038            ..dm_change(registry, ComponentOp::Insert, Some(&app_key))
1039        };
1040        assert!(validate_component_write(&outsider, &reg).is_err());
1041    }
1042
1043    /// A DM participant satisfies `ALLOW_IF_SUPER_ADMIN` on an application
1044    /// component and on nothing else.
1045    #[xmtp_common::test(unwrap_try = true)]
1046    // verifies: PERM-028
1047    fn dm_participant_is_super_admin_for_application_components_only() {
1048        for id in [ComponentId::new(0xC000), ComponentId::new(0xFEFF)] {
1049            let reg = setup_registry_with(id, super_admin_only(), super_admin_only(), deny());
1050            validate_component_write(&dm_change(id, ComponentOp::Update, None), &reg)?;
1051            assert!(
1052                validate_component_write(&dm_change(id, ComponentOp::Delete, None), &reg).is_err()
1053            );
1054        }
1055        for id in [
1056            ComponentId::GROUP_NAME,
1057            ComponentId::GROUP_MEMBERSHIP,
1058            ComponentId::new(0xBE00),
1059        ] {
1060            let reg = setup_registry_with(id, super_admin_only(), super_admin_only(), deny());
1061            assert!(matches!(
1062                validate_component_write(&dm_change(id, ComponentOp::Insert, None), &reg),
1063                Err(ComponentPermissionError::PermissionDenied(_, _))
1064            ));
1065        }
1066    }
1067}