Skip to main content

xmtp_mls_common/app_data/
validation.rs

1use xmtp_proto::xmtp::mls::message_contents::MetadataPolicy as MetadataPolicyProto;
2use xmtp_proto::xmtp::mls::message_contents::metadata_policy::{
3    Kind as MetadataPolicyKind, MetadataBasePolicy,
4};
5
6use super::component_id::ComponentId;
7use super::component_registry::{ComponentOp, ComponentRegistry, ComponentRegistryError};
8
9/// The minimal subset of actor authority needed to evaluate base policies.
10///
11/// Carries only the booleans the policy evaluator inspects (admin and
12/// super-admin status). This crate intentionally does not depend on the
13/// richer `CommitParticipant` type from `xmtp_mls` — callers at the
14/// integration boundary construct an `ActorAuthority` from whatever actor
15/// representation they have.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct ActorAuthority {
18    pub is_admin: bool,
19    pub is_super_admin: bool,
20}
21
22/// A change being proposed against a component, used as the input to
23/// [`validate_component_write`].
24///
25/// Carries everything needed to evaluate permissions for a single proposed
26/// write: the component identity, the operation, the actor performing it,
27/// and the raw old/new value bytes when available. Future change-aware base
28/// policies will inspect the value bytes; today's evaluator only looks at
29/// the actor.
30///
31/// `actor` is held by value (`ActorAuthority` is two booleans, smaller than
32/// a pointer), so the lifetime `'a` only constrains the borrowed
33/// `old_value` / `new_value` slices.
34///
35/// Construct via the generated builder so that `old_value` and `new_value`
36/// (which share the same type) can't be accidentally swapped:
37///
38/// ```
39/// # use xmtp_mls_common::app_data::component_id::ComponentId;
40/// # use xmtp_mls_common::app_data::component_registry::ComponentOp;
41/// # use xmtp_mls_common::app_data::validation::{ActorAuthority, ComponentChange};
42/// # let actor = ActorAuthority { is_admin: false, is_super_admin: true };
43/// # let old_bytes = vec![1, 2, 3];
44/// # let new_bytes = vec![4, 5, 6];
45/// let change = ComponentChange::builder()
46///     .component_id(ComponentId::GROUP_NAME)
47///     .op(ComponentOp::Update)
48///     .actor(actor)
49///     .old_value(&old_bytes)
50///     .new_value(&new_bytes)
51///     .build();
52/// ```
53#[derive(Debug, Clone, bon::Builder)]
54pub struct ComponentChange<'a> {
55    pub component_id: ComponentId,
56    pub op: ComponentOp,
57    pub actor: ActorAuthority,
58    pub old_value: Option<&'a [u8]>,
59    pub new_value: Option<&'a [u8]>,
60}
61
62#[derive(Debug, thiserror::Error)]
63pub enum ComponentPermissionError {
64    #[error("immutable component {0} does not allow {1}")]
65    ImmutableViolation(ComponentId, ComponentOp),
66    #[error("component {0} requires super admin")]
67    SuperAdminRequired(ComponentId),
68    #[error("no registry entry for component {0}")]
69    NoRegistryEntry(ComponentId),
70    #[error("missing permissions in metadata for component {0}")]
71    MissingPermissions(ComponentId),
72    #[error("missing policy field for component {0} op {1}")]
73    MissingPolicyField(ComponentId, ComponentOp),
74    #[error("invalid policy for component {0} op {1}")]
75    InvalidPolicy(ComponentId, ComponentOp),
76    #[error("permission denied for component {0} op {1}")]
77    PermissionDenied(ComponentId, ComponentOp),
78    #[error("registry error: {0}")]
79    RegistryError(#[from] ComponentRegistryError),
80}
81
82/// Validate whether the change's actor is allowed to perform the proposed
83/// [`ComponentChange`].
84///
85/// Three-layer check:
86/// 1. **Immutability**: Components in immutable ranges reject update and delete
87///    unconditionally — only insert is allowed (and only if the component
88///    doesn't exist yet, which the caller must verify).
89/// 2. **Hardcoded**: The hardcoded components (component registry, super admin
90///    list) have permissions enforced in code: super admin only.
91/// 3. **Registry lookup**: All other components must have an entry in the
92///    component registry. No entry = denied (deny by default).
93pub fn validate_component_write(
94    change: &ComponentChange<'_>,
95    registry: &ComponentRegistry,
96) -> Result<(), ComponentPermissionError> {
97    let component_id = change.component_id;
98    let op = change.op;
99
100    // Layer 1: Immutability check
101    if component_id.is_immutable() && matches!(op, ComponentOp::Update | ComponentOp::Delete) {
102        return Err(ComponentPermissionError::ImmutableViolation(
103            component_id,
104            op,
105        ));
106    }
107
108    // Layer 2: Hardcoded components — always require super admin.
109    // `is_hardcoded()` is the source of truth for which IDs land here:
110    // adding a new hardcoded component is a single-line change to that
111    // function and this branch picks it up automatically.
112    if component_id.is_hardcoded() {
113        return if change.actor.is_super_admin {
114            Ok(())
115        } else {
116            Err(ComponentPermissionError::SuperAdminRequired(component_id))
117        };
118    }
119
120    // Layer 3: Registry lookup (deny by default)
121    let meta = registry
122        .get(&component_id)?
123        .ok_or(ComponentPermissionError::NoRegistryEntry(component_id))?;
124
125    // The registry's `validate_metadata` guarantees that any stored entry
126    // has `permissions: Some` and all three policy fields populated. The
127    // checks below are defensive — they should never fire in practice but
128    // we'd rather return a structured error than panic.
129    let permissions = meta
130        .permissions
131        .ok_or(ComponentPermissionError::MissingPermissions(component_id))?;
132
133    let policy_proto: MetadataPolicyProto = match op {
134        ComponentOp::Insert => permissions.insert_policy,
135        ComponentOp::Update => permissions.update_policy,
136        ComponentOp::Delete => permissions.delete_policy,
137    }
138    .ok_or(ComponentPermissionError::MissingPolicyField(
139        component_id,
140        op,
141    ))?;
142
143    match evaluate_policy_proto(&policy_proto, change) {
144        PolicyOutcome::Allow => Ok(()),
145        PolicyOutcome::Deny => Err(ComponentPermissionError::PermissionDenied(component_id, op)),
146        PolicyOutcome::Invalid => Err(ComponentPermissionError::InvalidPolicy(component_id, op)),
147    }
148}
149
150/// Result of evaluating a [`MetadataPolicyProto`] against a [`ComponentChange`].
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum PolicyOutcome {
153    /// The policy permits the change.
154    Allow,
155    /// The policy denies the change.
156    Deny,
157    /// The policy is malformed (unknown base policy variant, empty combinator,
158    /// missing kind, etc.).
159    Invalid,
160}
161
162impl PolicyOutcome {
163    fn from_bool(allowed: bool) -> Self {
164        if allowed { Self::Allow } else { Self::Deny }
165    }
166}
167
168/// Walk a [`MetadataPolicyProto`] and evaluate it against the actor in a
169/// [`ComponentChange`].
170///
171/// Currently only inspects `change.actor` (matching the existing
172/// `MetadataBasePolicy` semantics). When change-aware base policies are added,
173/// this function will inspect `change.old_value` / `change.new_value` for the
174/// new variants — the field name → component ID mapping that the legacy
175/// `MetadataFieldChange` carried lives in `change.component_id`.
176///
177/// **Combinator semantics:**
178/// - `AndCondition` short-circuits on the first non-`Allow` outcome and
179///   propagates it (so `Deny` or `Invalid` wins over later siblings).
180/// - `AnyCondition` short-circuits on the first `Allow` *or* `Invalid`
181///   outcome — a single malformed sub-policy poisons the whole `OR`. The
182///   alternative ("keep scanning past `Invalid` looking for an `Allow`")
183///   would let a sender hide a structurally broken policy as long as
184///   *some* sibling allowed, which makes the broken policy invisible to
185///   peers and harder to repair. Failing closed on `Invalid` is the
186///   conservative choice.
187/// - Empty `AndCondition` / `AnyCondition` are `Invalid` rather than
188///   vacuously `Allow`/`Deny`.
189fn evaluate_policy_proto(
190    proto: &MetadataPolicyProto,
191    change: &ComponentChange<'_>,
192) -> PolicyOutcome {
193    match &proto.kind {
194        Some(MetadataPolicyKind::Base(base)) => evaluate_base_policy(*base, change.actor),
195        Some(MetadataPolicyKind::AndCondition(and)) => {
196            if and.policies.is_empty() {
197                return PolicyOutcome::Invalid;
198            }
199            for inner in &and.policies {
200                match evaluate_policy_proto(inner, change) {
201                    PolicyOutcome::Allow => continue,
202                    other => return other,
203                }
204            }
205            PolicyOutcome::Allow
206        }
207        Some(MetadataPolicyKind::AnyCondition(any)) => {
208            if any.policies.is_empty() {
209                return PolicyOutcome::Invalid;
210            }
211            for inner in &any.policies {
212                match evaluate_policy_proto(inner, change) {
213                    PolicyOutcome::Allow => return PolicyOutcome::Allow,
214                    PolicyOutcome::Deny => {}
215                    PolicyOutcome::Invalid => return PolicyOutcome::Invalid,
216                }
217            }
218            PolicyOutcome::Deny
219        }
220        None => PolicyOutcome::Invalid,
221    }
222}
223
224fn evaluate_base_policy(base: i32, actor: ActorAuthority) -> PolicyOutcome {
225    let base = match MetadataBasePolicy::try_from(base) {
226        Ok(b) => b,
227        Err(_) => return PolicyOutcome::Invalid,
228    };
229    match base {
230        MetadataBasePolicy::Allow => PolicyOutcome::Allow,
231        MetadataBasePolicy::Deny => PolicyOutcome::Deny,
232        MetadataBasePolicy::AllowIfAdmin => {
233            PolicyOutcome::from_bool(actor.is_admin || actor.is_super_admin)
234        }
235        MetadataBasePolicy::AllowIfSuperAdmin => PolicyOutcome::from_bool(actor.is_super_admin),
236        MetadataBasePolicy::Unspecified => PolicyOutcome::Invalid,
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::app_data::component_permissions::component_permissions;
244    use crate::app_data::component_registry::new_component_metadata;
245    use xmtp_proto::xmtp::mls::message_contents::{
246        ComponentType, MetadataPolicy as MetadataPolicyProto,
247        metadata_policy::{Kind as MetadataPolicyKind, MetadataBasePolicy},
248    };
249
250    fn make_policy(base: MetadataBasePolicy) -> MetadataPolicyProto {
251        MetadataPolicyProto {
252            kind: Some(MetadataPolicyKind::Base(base as i32)),
253        }
254    }
255
256    fn allow() -> MetadataPolicyProto {
257        make_policy(MetadataBasePolicy::Allow)
258    }
259
260    fn deny() -> MetadataPolicyProto {
261        make_policy(MetadataBasePolicy::Deny)
262    }
263
264    fn admin_only() -> MetadataPolicyProto {
265        make_policy(MetadataBasePolicy::AllowIfAdmin)
266    }
267
268    fn super_admin_only() -> MetadataPolicyProto {
269        make_policy(MetadataBasePolicy::AllowIfSuperAdmin)
270    }
271
272    fn make_actor(is_admin: bool, is_super_admin: bool) -> ActorAuthority {
273        ActorAuthority {
274            is_admin,
275            is_super_admin,
276        }
277    }
278
279    /// Test helper. Constructs a [`ComponentChange`] with no value bytes.
280    /// All current base policies only inspect the actor, so the value bytes
281    /// don't affect any test outcome — they're intentionally untested at
282    /// this layer until change-aware policies are added.
283    fn change<'a>(id: ComponentId, op: ComponentOp, actor: ActorAuthority) -> ComponentChange<'a> {
284        ComponentChange::builder()
285            .component_id(id)
286            .op(op)
287            .actor(actor)
288            .build()
289    }
290
291    fn member() -> ActorAuthority {
292        make_actor(false, false)
293    }
294
295    fn admin() -> ActorAuthority {
296        make_actor(true, false)
297    }
298
299    fn super_admin() -> ActorAuthority {
300        make_actor(true, true)
301    }
302
303    fn setup_registry_with(
304        id: ComponentId,
305        insert: MetadataPolicyProto,
306        update: MetadataPolicyProto,
307        delete: MetadataPolicyProto,
308    ) -> ComponentRegistry {
309        let mut reg = ComponentRegistry::new();
310        reg.set(
311            id,
312            new_component_metadata(
313                component_permissions()
314                    .insert(insert)
315                    .update(update)
316                    .delete(delete)
317                    .call(),
318                ComponentType::Bytes,
319            ),
320        )
321        .unwrap();
322        reg
323    }
324
325    // === Immutability Tests ===
326
327    #[xmtp_common::test]
328    fn test_immutable_insert_allowed() {
329        let id = ComponentId::CONVERSATION_TYPE;
330        let reg = setup_registry_with(id, allow(), deny(), deny());
331        let actor = super_admin();
332        let result = validate_component_write(&change(id, ComponentOp::Insert, actor), &reg);
333        assert!(result.is_ok());
334    }
335
336    #[xmtp_common::test]
337    fn test_immutable_update_rejected() {
338        let id = ComponentId::CONVERSATION_TYPE;
339        let reg = setup_registry_with(id, allow(), allow(), allow());
340        let actor = super_admin();
341        let result = validate_component_write(&change(id, ComponentOp::Update, actor), &reg);
342        assert!(matches!(
343            result,
344            Err(ComponentPermissionError::ImmutableViolation(
345                _,
346                ComponentOp::Update
347            ))
348        ));
349    }
350
351    #[xmtp_common::test]
352    fn test_immutable_delete_rejected() {
353        let id = ComponentId::CONVERSATION_TYPE;
354        let reg = setup_registry_with(id, allow(), allow(), allow());
355        let actor = super_admin();
356        let result = validate_component_write(&change(id, ComponentOp::Delete, actor), &reg);
357        assert!(matches!(
358            result,
359            Err(ComponentPermissionError::ImmutableViolation(
360                _,
361                ComponentOp::Delete
362            ))
363        ));
364    }
365
366    // === Hardcoded Tests ===
367
368    #[xmtp_common::test]
369    fn test_registry_super_admin_allowed() {
370        let reg = ComponentRegistry::new();
371        let actor = super_admin();
372        let result = validate_component_write(
373            &change(ComponentId::COMPONENT_REGISTRY, ComponentOp::Update, actor),
374            &reg,
375        );
376        assert!(result.is_ok());
377    }
378
379    #[xmtp_common::test]
380    fn test_registry_admin_rejected() {
381        let reg = ComponentRegistry::new();
382        let actor = admin();
383        let result = validate_component_write(
384            &change(ComponentId::COMPONENT_REGISTRY, ComponentOp::Update, actor),
385            &reg,
386        );
387        assert!(matches!(
388            result,
389            Err(ComponentPermissionError::SuperAdminRequired(_))
390        ));
391    }
392
393    #[xmtp_common::test]
394    fn test_registry_member_rejected() {
395        let reg = ComponentRegistry::new();
396        let actor = member();
397        let result = validate_component_write(
398            &change(ComponentId::COMPONENT_REGISTRY, ComponentOp::Update, actor),
399            &reg,
400        );
401        assert!(matches!(
402            result,
403            Err(ComponentPermissionError::SuperAdminRequired(_))
404        ));
405    }
406
407    #[xmtp_common::test]
408    fn test_super_admin_list_super_admin_allowed() {
409        let reg = ComponentRegistry::new();
410        let actor = super_admin();
411        let result = validate_component_write(
412            &change(ComponentId::SUPER_ADMIN_LIST, ComponentOp::Insert, actor),
413            &reg,
414        );
415        assert!(result.is_ok());
416    }
417
418    #[xmtp_common::test]
419    fn test_super_admin_list_admin_rejected() {
420        let reg = ComponentRegistry::new();
421        let actor = admin();
422        let result = validate_component_write(
423            &change(ComponentId::SUPER_ADMIN_LIST, ComponentOp::Insert, actor),
424            &reg,
425        );
426        assert!(matches!(
427            result,
428            Err(ComponentPermissionError::SuperAdminRequired(_))
429        ));
430    }
431
432    #[xmtp_common::test]
433    fn test_admin_list_with_admin_policy_admin_allowed() {
434        let reg = setup_registry_with(
435            ComponentId::ADMIN_LIST,
436            admin_only(),
437            admin_only(),
438            admin_only(),
439        );
440        let actor = admin();
441        let result = validate_component_write(
442            &change(ComponentId::ADMIN_LIST, ComponentOp::Insert, actor),
443            &reg,
444        );
445        assert!(result.is_ok());
446    }
447
448    #[xmtp_common::test]
449    fn test_admin_list_with_admin_policy_member_rejected() {
450        let reg = setup_registry_with(
451            ComponentId::ADMIN_LIST,
452            admin_only(),
453            admin_only(),
454            admin_only(),
455        );
456        let actor = member();
457        let result = validate_component_write(
458            &change(ComponentId::ADMIN_LIST, ComponentOp::Insert, actor),
459            &reg,
460        );
461        assert!(matches!(
462            result,
463            Err(ComponentPermissionError::PermissionDenied(_, _))
464        ));
465    }
466
467    #[xmtp_common::test]
468    fn test_admin_list_with_super_admin_policy() {
469        let reg = setup_registry_with(
470            ComponentId::ADMIN_LIST,
471            super_admin_only(),
472            super_admin_only(),
473            super_admin_only(),
474        );
475        let admin_actor = admin();
476        let super_admin_actor = super_admin();
477        // Admin rejected
478        assert!(
479            validate_component_write(
480                &change(ComponentId::ADMIN_LIST, ComponentOp::Insert, admin_actor),
481                &reg,
482            )
483            .is_err()
484        );
485        // Super admin allowed
486        assert!(
487            validate_component_write(
488                &change(
489                    ComponentId::ADMIN_LIST,
490                    ComponentOp::Insert,
491                    super_admin_actor,
492                ),
493                &reg,
494            )
495            .is_ok()
496        );
497    }
498
499    // === Registry Lookup Tests ===
500
501    #[xmtp_common::test]
502    fn test_deny_by_default_no_entry() {
503        let reg = ComponentRegistry::new();
504        let actor = super_admin();
505        let result = validate_component_write(
506            &change(ComponentId::GROUP_NAME, ComponentOp::Insert, actor),
507            &reg,
508        );
509        assert!(matches!(
510            result,
511            Err(ComponentPermissionError::NoRegistryEntry(_))
512        ));
513    }
514
515    #[xmtp_common::test]
516    fn test_insert_allow_policy() {
517        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), deny(), deny());
518        let actor = member();
519        let result = validate_component_write(
520            &change(ComponentId::GROUP_NAME, ComponentOp::Insert, actor),
521            &reg,
522        );
523        assert!(result.is_ok());
524    }
525
526    #[xmtp_common::test]
527    fn test_update_admin_only_policy_admin_passes() {
528        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), admin_only(), deny());
529        let actor = admin();
530        let result = validate_component_write(
531            &change(ComponentId::GROUP_NAME, ComponentOp::Update, actor),
532            &reg,
533        );
534        assert!(result.is_ok());
535    }
536
537    #[xmtp_common::test]
538    fn test_update_admin_only_policy_member_fails() {
539        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), admin_only(), deny());
540        let actor = member();
541        let result = validate_component_write(
542            &change(ComponentId::GROUP_NAME, ComponentOp::Update, actor),
543            &reg,
544        );
545        assert!(matches!(
546            result,
547            Err(ComponentPermissionError::PermissionDenied(
548                _,
549                ComponentOp::Update
550            ))
551        ));
552    }
553
554    #[xmtp_common::test]
555    fn test_delete_deny_policy() {
556        let reg = setup_registry_with(ComponentId::GROUP_NAME, allow(), allow(), deny());
557        let actor = super_admin();
558        let result = validate_component_write(
559            &change(ComponentId::GROUP_NAME, ComponentOp::Delete, actor),
560            &reg,
561        );
562        assert!(matches!(
563            result,
564            Err(ComponentPermissionError::PermissionDenied(
565                _,
566                ComponentOp::Delete
567            ))
568        ));
569    }
570
571    #[xmtp_common::test]
572    fn test_delete_super_admin_only_policy() {
573        let reg = setup_registry_with(
574            ComponentId::GROUP_NAME,
575            allow(),
576            allow(),
577            super_admin_only(),
578        );
579        let actor = super_admin();
580        let result = validate_component_write(
581            &change(ComponentId::GROUP_NAME, ComponentOp::Delete, actor),
582            &reg,
583        );
584        assert!(result.is_ok());
585    }
586
587    #[xmtp_common::test]
588    fn test_different_insert_vs_update_permissions() {
589        // Mimics group membership: anyone can update, only admin can insert
590        let reg = setup_registry_with(
591            ComponentId::GROUP_MEMBERSHIP,
592            admin_only(),
593            allow(),
594            admin_only(),
595        );
596        let member_actor = member();
597        let admin_actor = admin();
598
599        // Member can update
600        assert!(
601            validate_component_write(
602                &change(
603                    ComponentId::GROUP_MEMBERSHIP,
604                    ComponentOp::Update,
605                    member_actor,
606                ),
607                &reg,
608            )
609            .is_ok()
610        );
611
612        // Member cannot insert
613        assert!(
614            validate_component_write(
615                &change(
616                    ComponentId::GROUP_MEMBERSHIP,
617                    ComponentOp::Insert,
618                    member_actor,
619                ),
620                &reg,
621            )
622            .is_err()
623        );
624
625        // Admin can insert
626        assert!(
627            validate_component_write(
628                &change(
629                    ComponentId::GROUP_MEMBERSHIP,
630                    ComponentOp::Insert,
631                    admin_actor,
632                ),
633                &reg,
634            )
635            .is_ok()
636        );
637    }
638
639    #[xmtp_common::test]
640    fn test_app_range_component() {
641        let app_id = ComponentId::new(0xC100);
642        let reg = setup_registry_with(app_id, allow(), allow(), deny());
643        let actor = member();
644        let result = validate_component_write(&change(app_id, ComponentOp::Insert, actor), &reg);
645        assert!(result.is_ok());
646    }
647}