tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit dcb86ddb87f94fd7cbf13266186c67049407f118
parent 25a1d2101e5ba1482e63c01ef971e0ae27a2c954
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 17:51:49 -0700

feat: add strict group policy gates

- require AUTH-as-author for group writes

- enforce lifecycle membership and role policy

- gate hidden and private group reads

- reject closed joins without compat surfaces

Diffstat:
Mcrates/tangle_groups/src/errors.rs | 4++++
Mcrates/tangle_groups/src/lib.rs | 8+++++++-
Mcrates/tangle_groups/src/policy.rs | 745++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle_groups/src/projection.rs | 8++++++++
Mcrates/tangle_groups/src/read_gate.rs | 337++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle_groups/src/write_gate.rs | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 1189 insertions(+), 4 deletions(-)

diff --git a/crates/tangle_groups/src/errors.rs b/crates/tangle_groups/src/errors.rs @@ -47,6 +47,10 @@ pub enum GroupErrorKind { InvalidRole, MissingCapability, AuthenticationRequired, + GroupUnavailable, + GroupDeleted, + GroupAlreadyExists, + DuplicateMember, Internal, } diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs @@ -35,6 +35,9 @@ pub use outbox::{ GroupOutboxPayload, GroupOutboxRecord, GroupOutboxStatus, OutboxRecoveryReadiness, OutboxReplayPlan, }; +pub use policy::{ + GroupAuthority, GroupWriteDecision, GroupWritePolicy, non_enumerating_group_error, +}; pub use projection::{ CanonicalGroupEvent, GROUP_POLICY_VERSION, GROUP_PROJECTION_SCHEMA_VERSION, GroupLifecycleState, GroupProjection, GroupRecoveryReadiness, GroupSnapshotIds, GroupState, @@ -43,12 +46,15 @@ pub use projection::{ group_current_key, member_current_key, projection_checkpoint_key, rebuild_group_projection, role_current_key, tombstone_key, }; +pub use read_gate::{GroupReadDecision, GroupReadGate}; pub use roles::{ Capability, CapabilitySet, PERMANENT_RELAY_OVERRIDE_ROLE, RoleDefinition, RoleName, resolve_capabilities, }; pub use tags::{GroupTag, GroupTagName, extract_group_tag, has_group_identity_tag}; -pub use write_gate::validate_client_group_event_structure; +pub use write_gate::{ + GroupAuthContext, require_group_auth_as_author, validate_client_group_event_structure, +}; #[derive(Clone, PartialEq, Eq)] pub struct RelaySecret(String); diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -1,2 +1,745 @@ +use std::collections::BTreeSet; + +use crate::{ + Capability, CapabilitySet, GroupError, GroupErrorKind, GroupEventClass, GroupId, + GroupLifecycleState, GroupProjection, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, + KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, + KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, + MemberStatus, RoleDefinition, RoleName, SupportedKinds, require_group_auth_as_author, + resolve_capabilities, +}; +use tangle_protocol::{Event, PublicKeyHex, Tag}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GroupAuthority { + owner_pubkeys: BTreeSet<PublicKeyHex>, + admin_pubkeys: BTreeSet<PublicKeyHex>, +} + +impl GroupAuthority { + pub fn new( + owner_pubkeys: impl IntoIterator<Item = PublicKeyHex>, + admin_pubkeys: impl IntoIterator<Item = PublicKeyHex>, + ) -> Self { + Self { + owner_pubkeys: owner_pubkeys.into_iter().collect(), + admin_pubkeys: admin_pubkeys.into_iter().collect(), + } + } + + pub fn empty() -> Self { + Self::default() + } + + pub fn is_owner(&self, pubkey: &PublicKeyHex) -> bool { + self.owner_pubkeys.contains(pubkey) + } + + pub fn is_admin(&self, pubkey: &PublicKeyHex) -> bool { + self.admin_pubkeys.contains(pubkey) || self.is_owner(pubkey) + } + + pub fn is_permanent_admin(&self, pubkey: &PublicKeyHex) -> bool { + self.is_admin(pubkey) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GroupPolicyBoundary; +pub enum GroupWriteDecision { + Accept, + IgnoreNonGroup, +} + +#[derive(Debug, Clone, Copy)] +pub struct GroupWritePolicy<'a> { + projection: &'a GroupProjection, + authority: &'a GroupAuthority, +} + +impl<'a> GroupWritePolicy<'a> { + pub fn new(projection: &'a GroupProjection, authority: &'a GroupAuthority) -> Self { + Self { + projection, + authority, + } + } + + pub fn check_event( + &self, + event: &Event, + class: &GroupEventClass, + auth: &crate::GroupAuthContext, + ) -> Result<GroupWriteDecision, GroupError> { + require_group_auth_as_author(event, class, auth)?; + match class { + GroupEventClass::NonGroup => Ok(GroupWriteDecision::IgnoreNonGroup), + GroupEventClass::RelayGeneratedSnapshot { .. } => Err(GroupError::blocked( + GroupErrorKind::DirectRelayGeneratedSubmission, + "relay-generated group state events cannot be submitted by clients", + )), + GroupEventClass::Moderation { kind, group_id } => { + self.check_moderation_event(event, kind.as_u32(), group_id) + } + GroupEventClass::Normal { group_id } => self.check_normal_event(event, group_id), + } + } + + pub fn can_read_group(&self, group_id: &GroupId, reader: Option<&PublicKeyHex>) -> bool { + let Some(reader) = reader else { + return false; + }; + self.authority.is_admin(reader) || self.is_current_member(group_id, reader) + } + + pub fn has_relay_override(&self, group_id: &GroupId, pubkey: &PublicKeyHex) -> bool { + if self.authority.is_admin(pubkey) { + return true; + } + self.projection + .member(group_id, pubkey) + .filter(|member| member.status() == MemberStatus::Member) + .is_some_and(|member| { + member + .roles() + .contains(&RoleName::permanent_relay_override()) + }) + } + + fn check_moderation_event( + &self, + event: &Event, + kind: u32, + group_id: &GroupId, + ) -> Result<GroupWriteDecision, GroupError> { + if kind == KIND_GROUP_CREATE_GROUP { + return self.check_create_group(event, group_id); + } + let group = self.require_active_group(group_id)?; + let required = required_capability(kind, event.unsigned().tags())?; + if let Some(required) = required { + self.require_capability(group_id, event.unsigned().pubkey(), required)?; + } + if kind == KIND_GROUP_REMOVE_USER { + let target = target_pubkey(event, "p")?; + if self.is_protected_admin(group_id, &target) { + return Err(GroupError::restricted( + GroupErrorKind::MissingCapability, + "permanent group admins cannot be removed", + )); + } + } + if kind == KIND_GROUP_EDIT_METADATA { + if group.metadata().hidden() + && !self.can_read_group(group_id, Some(event.unsigned().pubkey())) + { + return Err(non_enumerating_group_error()); + } + } + Ok(GroupWriteDecision::Accept) + } + + fn check_create_group( + &self, + event: &Event, + group_id: &GroupId, + ) -> Result<GroupWriteDecision, GroupError> { + if !self.authority.is_owner(event.unsigned().pubkey()) { + return Err(GroupError::restricted( + GroupErrorKind::MissingCapability, + "group creation is restricted to relay owners", + )); + } + if self.projection.tombstone(group_id).is_some() { + return Err(GroupError::blocked( + GroupErrorKind::GroupDeleted, + "group is deleted", + )); + } + if self.projection.group(group_id).is_some() { + return Err(GroupError::invalid( + GroupErrorKind::GroupAlreadyExists, + "group already exists", + )); + } + Ok(GroupWriteDecision::Accept) + } + + fn check_normal_event( + &self, + event: &Event, + group_id: &GroupId, + ) -> Result<GroupWriteDecision, GroupError> { + let group = self.require_active_group(group_id)?; + match event.unsigned().kind().as_u32() { + KIND_GROUP_JOIN_REQUEST => self.check_join(event, group_id), + KIND_GROUP_LEAVE_REQUEST => self.check_leave(event, group_id), + _ => { + if group.metadata().restricted() + && !self.can_read_group(group_id, Some(event.unsigned().pubkey())) + { + return Err(non_enumerating_group_error()); + } + match group.metadata().supported_kinds() { + SupportedKinds::UnspecifiedAll => {} + SupportedKinds::None => { + return Err(GroupError::restricted( + GroupErrorKind::UnsupportedGroupKind, + "group does not accept normal event kinds", + )); + } + SupportedKinds::Only(kinds) => { + if !kinds.contains(&event.unsigned().kind()) { + return Err(GroupError::restricted( + GroupErrorKind::UnsupportedGroupKind, + "event kind is not supported by this group", + )); + } + } + } + Ok(GroupWriteDecision::Accept) + } + } + } + + fn check_join( + &self, + event: &Event, + group_id: &GroupId, + ) -> Result<GroupWriteDecision, GroupError> { + let group = self.require_active_group(group_id)?; + if self.is_current_member(group_id, event.unsigned().pubkey()) { + return Err(GroupError::invalid( + GroupErrorKind::DuplicateMember, + "group member already exists", + )); + } + if group.metadata().closed() { + return Err(non_enumerating_group_error()); + } + Ok(GroupWriteDecision::Accept) + } + + fn check_leave( + &self, + event: &Event, + group_id: &GroupId, + ) -> Result<GroupWriteDecision, GroupError> { + self.require_active_group(group_id)?; + if !self.is_current_member(group_id, event.unsigned().pubkey()) { + return Err(GroupError::invalid( + GroupErrorKind::DuplicateMember, + "group member does not exist", + )); + } + Ok(GroupWriteDecision::Accept) + } + + fn require_active_group(&self, group_id: &GroupId) -> Result<&crate::GroupState, GroupError> { + let Some(group) = self.projection.group(group_id) else { + return Err(non_enumerating_group_error()); + }; + if group.lifecycle() == GroupLifecycleState::Deleted + || self.projection.tombstone(group_id).is_some() + { + return Err(GroupError::blocked( + GroupErrorKind::GroupDeleted, + "group is deleted", + )); + } + Ok(group) + } + + fn require_capability( + &self, + group_id: &GroupId, + actor: &PublicKeyHex, + required: Capability, + ) -> Result<(), GroupError> { + if self.authority.is_admin(actor) { + return Ok(()); + } + let capabilities = self.actor_capabilities(group_id, actor)?; + if capabilities.contains(required) { + return Ok(()); + } + Err(GroupError::restricted( + GroupErrorKind::MissingCapability, + format!("missing group capability {}", required.as_str()), + )) + } + + fn actor_capabilities( + &self, + group_id: &GroupId, + actor: &PublicKeyHex, + ) -> Result<CapabilitySet, GroupError> { + let Some(member) = self.projection.member(group_id, actor) else { + return Ok(CapabilitySet::empty()); + }; + if member.status() != MemberStatus::Member { + return Ok(CapabilitySet::empty()); + } + let definitions = self + .projection + .roles() + .iter() + .filter(|((candidate_group, _), _)| candidate_group == group_id) + .map(|(_, role)| role.definition()) + .collect::<Vec<&RoleDefinition>>(); + resolve_capabilities(definitions, member.roles().iter()) + } + + fn is_current_member(&self, group_id: &GroupId, pubkey: &PublicKeyHex) -> bool { + self.projection + .member(group_id, pubkey) + .is_some_and(|member| member.status() == MemberStatus::Member) + } + + fn is_protected_admin(&self, group_id: &GroupId, pubkey: &PublicKeyHex) -> bool { + self.authority.is_permanent_admin(pubkey) || self.has_relay_override(group_id, pubkey) + } +} + +pub fn non_enumerating_group_error() -> GroupError { + GroupError::restricted(GroupErrorKind::GroupUnavailable, "group is unavailable") +} + +fn required_capability(kind: u32, tags: &[Tag]) -> Result<Option<Capability>, GroupError> { + match kind { + KIND_GROUP_PUT_USER => { + if has_role_tag(tags) { + Ok(Some(Capability::ManageRoles)) + } else { + Ok(Some(Capability::ManageMembers)) + } + } + KIND_GROUP_REMOVE_USER => Ok(Some(Capability::ManageMembers)), + KIND_GROUP_EDIT_METADATA => Ok(Some(Capability::ManageMetadata)), + KIND_GROUP_DELETE_EVENT => Ok(Some(Capability::DeleteEvents)), + KIND_GROUP_DELETE_GROUP => Ok(Some(Capability::DeleteGroup)), + KIND_GROUP_CREATE_INVITE => Ok(Some(Capability::CreateInvites)), + _ => Ok(None), + } +} + +fn has_role_tag(tags: &[Tag]) -> bool { + tags.iter() + .any(|tag| tag.values().first().is_some_and(|name| name == "role")) +} + +fn target_pubkey(event: &Event, tag_name: &str) -> Result<PublicKeyHex, GroupError> { + for tag in event.unsigned().tags() { + if !tag + .values() + .first() + .is_some_and(|candidate| candidate == tag_name) + { + continue; + } + let Some((_, value)) = tag.indexed_pair() else { + return Err(GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed {tag_name} target tag"), + )); + }; + return PublicKeyHex::new(value).map_err(|reason| { + GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed {tag_name} target tag: {reason}"), + ) + }); + } + Err(GroupError::invalid( + GroupErrorKind::MissingTargetTag, + format!("missing {tag_name} target tag"), + )) +} + +#[cfg(test)] +mod tests { + use super::{GroupAuthority, GroupWriteDecision, GroupWritePolicy}; + use crate::{ + Capability, CapabilitySet, GroupAuthContext, GroupErrorKind, GroupEventClass, GroupId, + GroupMetadata, GroupProjection, GroupState, KIND_GROUP_CREATE_GROUP, + KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, + KIND_GROUP_REMOVE_USER, MemberState, MemberStatus, ProjectedRoleDefinition, + ProjectionOrderTuple, RoleDefinition, RoleName, StoreOffset, SupportedKinds, + }; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; + + #[test] + fn group_create_requires_relay_owner_and_unused_group_id() { + let projection = GroupProjection::new(); + let owner = pubkey("1"); + let author = pubkey("2"); + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + let create_by_non_owner = event(KIND_GROUP_CREATE_GROUP, author.clone(), vec![h("Farm")]); + let class = GroupEventClass::Moderation { + kind: create_by_non_owner.unsigned().kind(), + group_id: group("Farm"), + }; + + assert_eq!( + policy + .check_event( + &create_by_non_owner, + &class, + &GroupAuthContext::new([author.clone()]) + ) + .expect_err("owner") + .kind(), + GroupErrorKind::MissingCapability + ); + + let owner_event = event(KIND_GROUP_CREATE_GROUP, owner.clone(), vec![h("Farm")]); + assert_eq!( + policy + .check_event( + &owner_event, + &class, + &GroupAuthContext::new([owner.clone()]) + ) + .expect("accept"), + GroupWriteDecision::Accept + ); + } + + #[test] + fn lifecycle_policy_rejects_nonexistent_deleted_and_duplicate_groups() { + let owner = pubkey("1"); + let mut projection = projection_with_group( + "Farm", + metadata(false, false, false, false, SupportedKinds::UnspecifiedAll), + owner.clone(), + ); + let group_id = group("Farm"); + let class = GroupEventClass::Moderation { + kind: kind(KIND_GROUP_CREATE_GROUP), + group_id: group_id.clone(), + }; + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + let create = event(KIND_GROUP_CREATE_GROUP, owner.clone(), vec![h("Farm")]); + + assert_eq!( + policy + .check_event(&create, &class, &GroupAuthContext::new([owner.clone()])) + .expect_err("duplicate") + .kind(), + GroupErrorKind::GroupAlreadyExists + ); + + let delete = event(KIND_GROUP_DELETE_GROUP, owner.clone(), vec![h("Farm")]); + projection + .apply_canonical_event(&delete, StoreOffset::new(2), Default::default()) + .expect("delete"); + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + let normal = event(1, owner.clone(), vec![h("Farm")]); + + assert_eq!( + policy + .check_event( + &normal, + &GroupEventClass::Normal { + group_id: group_id.clone() + }, + &GroupAuthContext::new([owner]) + ) + .expect_err("deleted") + .kind(), + GroupErrorKind::GroupDeleted + ); + } + + #[test] + fn restricted_and_supported_kind_rules_gate_normal_writes() { + let owner = pubkey("1"); + let member = pubkey("2"); + let outsider = pubkey("3"); + let mut projection = projection_with_group( + "Farm", + metadata( + true, + false, + false, + false, + SupportedKinds::Only([kind(1)].into_iter().collect()), + ), + owner.clone(), + ); + put_member(&mut projection, "Farm", member.clone(), []); + let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + + assert_eq!( + policy + .check_event( + &event(1, outsider.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm") + }, + &GroupAuthContext::new([outsider.clone()]) + ) + .expect_err("restricted") + .kind(), + GroupErrorKind::GroupUnavailable + ); + assert_eq!( + policy + .check_event( + &event(7, member.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm") + }, + &GroupAuthContext::new([member]) + ) + .expect_err("kind") + .kind(), + GroupErrorKind::UnsupportedGroupKind + ); + } + + #[test] + fn moderation_policy_uses_roles_and_protects_permanent_admins() { + let owner = pubkey("1"); + let moderator = pubkey("2"); + let protected = pubkey("3"); + let target = pubkey("4"); + let mut projection = projection_with_group( + "Farm", + metadata(false, false, false, false, SupportedKinds::UnspecifiedAll), + owner.clone(), + ); + let moderator_role = RoleName::new("moderator").expect("role"); + projection.put_role( + group("Farm"), + ProjectedRoleDefinition::new( + RoleDefinition::new( + moderator_role.clone(), + CapabilitySet::new([Capability::ManageMembers]), + None, + ), + event_id("30"), + tuple(30, "30", 3), + ), + ); + put_member(&mut projection, "Farm", moderator.clone(), [moderator_role]); + let authority = GroupAuthority::new([owner], [protected.clone()]); + let policy = GroupWritePolicy::new(&projection, &authority); + + assert_eq!( + policy + .check_event( + &event( + KIND_GROUP_REMOVE_USER, + moderator.clone(), + vec![h("Farm"), p(&target)] + ), + &GroupEventClass::Moderation { + kind: kind(KIND_GROUP_REMOVE_USER), + group_id: group("Farm") + }, + &GroupAuthContext::new([moderator.clone()]) + ) + .expect("moderator"), + GroupWriteDecision::Accept + ); + assert_eq!( + policy + .check_event( + &event( + KIND_GROUP_REMOVE_USER, + moderator.clone(), + vec![h("Farm"), p(&protected)] + ), + &GroupEventClass::Moderation { + kind: kind(KIND_GROUP_REMOVE_USER), + group_id: group("Farm") + }, + &GroupAuthContext::new([moderator]) + ) + .expect_err("protected") + .kind(), + GroupErrorKind::MissingCapability + ); + } + + #[test] + fn join_and_leave_policy_is_immediate_and_membership_based() { + let owner = pubkey("1"); + let joiner = pubkey("2"); + let member = pubkey("3"); + let mut projection = projection_with_group( + "Farm", + metadata(false, false, false, false, SupportedKinds::UnspecifiedAll), + owner.clone(), + ); + put_member(&mut projection, "Farm", member.clone(), []); + let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + + assert_eq!( + policy + .check_event( + &event(KIND_GROUP_JOIN_REQUEST, joiner.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm") + }, + &GroupAuthContext::new([joiner]) + ) + .expect("join"), + GroupWriteDecision::Accept + ); + assert_eq!( + policy + .check_event( + &event(KIND_GROUP_JOIN_REQUEST, member.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm") + }, + &GroupAuthContext::new([member.clone()]) + ) + .expect_err("duplicate join") + .kind(), + GroupErrorKind::DuplicateMember + ); + assert_eq!( + policy + .check_event( + &event(KIND_GROUP_LEAVE_REQUEST, member.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm") + }, + &GroupAuthContext::new([member]) + ) + .expect("leave"), + GroupWriteDecision::Accept + ); + } + + #[test] + fn closed_group_denies_public_join_strictly() { + let owner = pubkey("1"); + let joiner = pubkey("2"); + let projection = projection_with_group( + "Farm", + metadata(false, false, false, true, SupportedKinds::UnspecifiedAll), + owner.clone(), + ); + let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + + assert_eq!( + policy + .check_event( + &event(KIND_GROUP_JOIN_REQUEST, joiner.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm") + }, + &GroupAuthContext::new([joiner]) + ) + .expect_err("closed") + .kind(), + GroupErrorKind::GroupUnavailable + ); + } + + fn projection_with_group( + group_id: &str, + metadata: GroupMetadata, + author: PublicKeyHex, + ) -> GroupProjection { + let mut projection = GroupProjection::new(); + projection.put_group(GroupState::new( + group(group_id), + metadata, + author, + event_id("10"), + tuple(10, "10", 1), + )); + projection + } + + fn put_member( + projection: &mut GroupProjection, + group_id: &str, + pubkey: PublicKeyHex, + roles: impl IntoIterator<Item = RoleName>, + ) { + projection.put_member( + group(group_id), + MemberState::new( + pubkey, + MemberStatus::Member, + roles.into_iter().collect(), + event_id("20"), + tuple(20, "20", 2), + ), + ); + } + + fn metadata( + restricted: bool, + private: bool, + hidden: bool, + closed: bool, + supported_kinds: SupportedKinds, + ) -> GroupMetadata { + GroupMetadata::new( + None, + None, + None, + private, + restricted, + hidden, + closed, + supported_kinds, + ) + } + + fn event(kind_value: u32, pubkey: PublicKeyHex, tags: Vec<Tag>) -> Event { + Event::new( + event_id("01"), + UnsignedEvent::new(pubkey, UnixTimestamp::new(1), kind(kind_value), tags, ""), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) + } + + fn h(group_id: &str) -> Tag { + Tag::from_parts("h", &[group_id]).expect("h") + } + + fn p(pubkey: &PublicKeyHex) -> Tag { + Tag::from_parts("p", &[pubkey.as_str()]).expect("p") + } + + fn group(value: &str) -> GroupId { + GroupId::new(value).expect("group") + } + + fn pubkey(suffix: &str) -> PublicKeyHex { + PublicKeyHex::new(&suffix.repeat(64)).expect("pubkey") + } + + fn kind(value: u32) -> Kind { + Kind::new(value.into()).expect("kind") + } + + fn tuple(created_at: u64, suffix: &str, offset: u64) -> ProjectionOrderTuple { + ProjectionOrderTuple::new( + UnixTimestamp::new(created_at), + event_id(suffix), + StoreOffset::new(offset), + ) + } + + fn event_id(suffix: &str) -> EventId { + let mut value = "0".repeat(64 - suffix.len()); + value.push_str(suffix); + EventId::new(&value).expect("id") + } +} diff --git a/crates/tangle_groups/src/projection.rs b/crates/tangle_groups/src/projection.rs @@ -518,6 +518,14 @@ impl GroupProjection { &self.members } + pub fn roles(&self) -> &BTreeMap<(GroupId, RoleName), ProjectedRoleDefinition> { + &self.roles + } + + pub fn tombstones(&self) -> &BTreeMap<GroupId, GroupTombstone> { + &self.tombstones + } + pub fn checkpoint(&self) -> Option<&ProjectionCheckpoint> { self.checkpoint.as_ref() } diff --git a/crates/tangle_groups/src/read_gate.rs b/crates/tangle_groups/src/read_gate.rs @@ -1,2 +1,337 @@ +use crate::{ + GroupAuthority, GroupError, GroupEventClass, GroupId, GroupLimitsConfig, GroupProjection, + MemberStatus, classify_group_event, non_enumerating_group_error, +}; +use tangle_protocol::{Event, PublicKeyHex}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GroupReadGateBoundary; +pub enum GroupReadDecision { + Visible, + Hidden, +} + +#[derive(Debug, Clone, Copy)] +pub struct GroupReadGate<'a> { + projection: &'a GroupProjection, + authority: &'a GroupAuthority, +} + +impl<'a> GroupReadGate<'a> { + pub fn new(projection: &'a GroupProjection, authority: &'a GroupAuthority) -> Self { + Self { + projection, + authority, + } + } + + pub fn screen_event( + &self, + event: &Event, + reader: Option<&PublicKeyHex>, + limits: GroupLimitsConfig, + ) -> Result<GroupReadDecision, GroupError> { + match classify_group_event(event, limits)? { + GroupEventClass::NonGroup => Ok(GroupReadDecision::Visible), + GroupEventClass::Normal { group_id } => self.screen_normal_event(&group_id, reader), + GroupEventClass::Moderation { group_id, .. } => { + self.screen_normal_event(&group_id, reader) + } + GroupEventClass::RelayGeneratedSnapshot { kind, group_id } => { + self.screen_snapshot_event(kind.as_u32(), &group_id, reader) + } + } + } + + pub fn require_visible( + &self, + event: &Event, + reader: Option<&PublicKeyHex>, + limits: GroupLimitsConfig, + ) -> Result<(), GroupError> { + match self.screen_event(event, reader, limits)? { + GroupReadDecision::Visible => Ok(()), + GroupReadDecision::Hidden => Err(non_enumerating_group_error()), + } + } + + fn screen_snapshot_event( + &self, + _kind: u32, + group_id: &GroupId, + reader: Option<&PublicKeyHex>, + ) -> Result<GroupReadDecision, GroupError> { + let Some(group) = self.projection.group(group_id) else { + return Ok(GroupReadDecision::Hidden); + }; + if self.projection.tombstone(group_id).is_some() { + return Ok(GroupReadDecision::Hidden); + } + if group.metadata().hidden() && !self.can_read_group(group_id, reader) { + return Ok(GroupReadDecision::Hidden); + } + if group.metadata().private() && !self.can_read_group(group_id, reader) { + return Ok(GroupReadDecision::Hidden); + } + Ok(GroupReadDecision::Visible) + } + + fn screen_normal_event( + &self, + group_id: &GroupId, + reader: Option<&PublicKeyHex>, + ) -> Result<GroupReadDecision, GroupError> { + let Some(group) = self.projection.group(group_id) else { + return Ok(GroupReadDecision::Hidden); + }; + if self.projection.tombstone(group_id).is_some() { + return Ok(GroupReadDecision::Hidden); + } + if (group.metadata().hidden() || group.metadata().private()) + && !self.can_read_group(group_id, reader) + { + return Ok(GroupReadDecision::Hidden); + } + Ok(GroupReadDecision::Visible) + } + + fn can_read_group(&self, group_id: &GroupId, reader: Option<&PublicKeyHex>) -> bool { + let Some(reader) = reader else { + return false; + }; + if self.authority.is_admin(reader) { + return true; + } + self.projection + .member(group_id, reader) + .is_some_and(|member| member.status() == MemberStatus::Member) + } +} + +#[cfg(test)] +mod tests { + use super::{GroupReadDecision, GroupReadGate}; + use crate::{ + GroupAuthority, GroupId, GroupMetadata, GroupProjection, GroupState, KIND_GROUP_METADATA, + MemberState, MemberStatus, ProjectionOrderTuple, StoreOffset, SupportedKinds, + }; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; + + #[test] + fn read_gate_allows_public_group_events_and_hides_unknown_groups() { + let owner = pubkey("1"); + let projection = projection_with_group( + "Farm", + GroupMetadata::new( + None, + None, + None, + false, + false, + false, + false, + SupportedKinds::UnspecifiedAll, + ), + owner, + ); + let authority = GroupAuthority::empty(); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.screen_event(&event(1, vec![h("Farm")]), None, Default::default()) + .expect("public"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event(&event(1, vec![h("Other")]), None, Default::default()) + .expect("unknown"), + GroupReadDecision::Hidden + ); + } + + #[test] + fn read_gate_hides_hidden_and_private_group_events_from_non_members() { + let owner = pubkey("1"); + let member = pubkey("2"); + let outsider = pubkey("3"); + let mut projection = projection_with_group( + "Farm", + GroupMetadata::new( + None, + None, + None, + true, + false, + true, + false, + SupportedKinds::UnspecifiedAll, + ), + owner.clone(), + ); + put_member(&mut projection, "Farm", member.clone()); + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.screen_event( + &event(1, vec![h("Farm")]), + Some(&outsider), + Default::default() + ) + .expect("outsider"), + GroupReadDecision::Hidden + ); + assert_eq!( + gate.screen_event( + &event(1, vec![h("Farm")]), + Some(&member), + Default::default() + ) + .expect("member"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event(&event(1, vec![h("Farm")]), Some(&owner), Default::default()) + .expect("owner"), + GroupReadDecision::Visible + ); + } + + #[test] + fn read_gate_hides_hidden_and_private_snapshots_from_non_members() { + let owner = pubkey("1"); + let projection = projection_with_group( + "Farm", + GroupMetadata::new( + None, + None, + None, + true, + false, + true, + false, + SupportedKinds::UnspecifiedAll, + ), + owner.clone(), + ); + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.screen_event( + &event(KIND_GROUP_METADATA, vec![d("Farm")]), + None, + Default::default() + ) + .expect("hidden"), + GroupReadDecision::Hidden + ); + assert_eq!( + gate.screen_event( + &event(KIND_GROUP_METADATA, vec![d("Farm")]), + Some(&owner), + Default::default() + ) + .expect("owner"), + GroupReadDecision::Visible + ); + } + + #[test] + fn require_visible_uses_non_enumerating_error() { + let owner = pubkey("1"); + let projection = projection_with_group( + "Farm", + GroupMetadata::new( + None, + None, + None, + true, + false, + true, + false, + SupportedKinds::UnspecifiedAll, + ), + owner, + ); + let authority = GroupAuthority::empty(); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.require_visible(&event(1, vec![h("Farm")]), None, Default::default()) + .expect_err("hidden") + .message(), + "group is unavailable" + ); + } + + fn projection_with_group( + group_id: &str, + metadata: GroupMetadata, + author: PublicKeyHex, + ) -> GroupProjection { + let mut projection = GroupProjection::new(); + projection.put_group(GroupState::new( + GroupId::new(group_id).expect("group"), + metadata, + author, + event_id("10"), + tuple(10, "10", 1), + )); + projection + } + + fn put_member(projection: &mut GroupProjection, group_id: &str, pubkey: PublicKeyHex) { + projection.put_member( + GroupId::new(group_id).expect("group"), + MemberState::new( + pubkey, + MemberStatus::Member, + Default::default(), + event_id("20"), + tuple(20, "20", 2), + ), + ); + } + + fn event(kind_value: u32, tags: Vec<Tag>) -> Event { + Event::new( + event_id("01"), + UnsignedEvent::new( + pubkey("9"), + UnixTimestamp::new(1), + Kind::new(kind_value.into()).expect("kind"), + tags, + "", + ), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) + } + + fn h(group_id: &str) -> Tag { + Tag::from_parts("h", &[group_id]).expect("h") + } + + fn d(group_id: &str) -> Tag { + Tag::from_parts("d", &[group_id]).expect("d") + } + + fn pubkey(suffix: &str) -> PublicKeyHex { + PublicKeyHex::new(&suffix.repeat(64)).expect("pubkey") + } + + fn tuple(created_at: u64, suffix: &str, offset: u64) -> ProjectionOrderTuple { + ProjectionOrderTuple::new( + UnixTimestamp::new(created_at), + event_id(suffix), + StoreOffset::new(offset), + ) + } + + fn event_id(suffix: &str) -> EventId { + let mut value = "0".repeat(64 - suffix.len()); + value.push_str(suffix); + EventId::new(&value).expect("id") + } +} diff --git a/crates/tangle_groups/src/write_gate.rs b/crates/tangle_groups/src/write_gate.rs @@ -5,8 +5,34 @@ use crate::{ kinds::{KIND_GROUP_DELETE_EVENT, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER}, tags::ensure_group_tag_limit, }; +use std::collections::BTreeSet; use tangle_protocol::{Event, PublicKeyHex}; +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GroupAuthContext { + authenticated_pubkeys: BTreeSet<PublicKeyHex>, +} + +impl GroupAuthContext { + pub fn unauthenticated() -> Self { + Self::default() + } + + pub fn new(pubkeys: impl IntoIterator<Item = PublicKeyHex>) -> Self { + Self { + authenticated_pubkeys: pubkeys.into_iter().collect(), + } + } + + pub fn contains(&self, pubkey: &PublicKeyHex) -> bool { + self.authenticated_pubkeys.contains(pubkey) + } + + pub fn authenticated_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { + &self.authenticated_pubkeys + } +} + pub fn validate_client_group_event_structure( event: &Event, limits: GroupLimitsConfig, @@ -26,6 +52,22 @@ pub fn validate_client_group_event_structure( } } +pub fn require_group_auth_as_author( + event: &Event, + class: &GroupEventClass, + auth: &GroupAuthContext, +) -> Result<(), GroupError> { + if matches!(class, GroupEventClass::NonGroup) { + return Ok(()); + } + if auth.contains(event.unsigned().pubkey()) { + return Ok(()); + } + Err(GroupError::auth_required( + "group event author must authenticate with AUTH", + )) +} + fn validate_moderation_targets(event: &Event, kind: u32) -> Result<(), GroupError> { match kind { KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => require_valid_p_tag(event), @@ -70,7 +112,9 @@ fn require_indexed_tag_value<'a>(event: &'a Event, name: &str) -> Result<&'a str #[cfg(test)] mod tests { - use super::validate_client_group_event_structure; + use super::{ + GroupAuthContext, require_group_auth_as_author, validate_client_group_event_structure, + }; use crate::{ GroupErrorKind, GroupEventClass, GroupLimitsConfig, KIND_GROUP_DELETE_EVENT, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, @@ -171,6 +215,51 @@ mod tests { )); } + #[test] + fn group_write_auth_requires_event_author() { + let group_event = event(1, vec![Tag::from_parts("h", &["Farm"]).expect("h")]); + let class = + validate_client_group_event_structure(&group_event, GroupLimitsConfig::default()) + .expect("class"); + + assert_eq!( + require_group_auth_as_author( + &group_event, + &class, + &GroupAuthContext::unauthenticated() + ) + .expect_err("auth") + .kind(), + GroupErrorKind::AuthenticationRequired + ); + assert!( + require_group_auth_as_author( + &group_event, + &class, + &GroupAuthContext::new([PublicKeyHex::new(&"1".repeat(64)).expect("pubkey")]) + ) + .is_ok() + ); + assert_eq!( + require_group_auth_as_author( + &group_event, + &class, + &GroupAuthContext::new([PublicKeyHex::new(&"3".repeat(64)).expect("pubkey")]) + ) + .expect_err("wrong author") + .kind(), + GroupErrorKind::AuthenticationRequired + ); + assert!( + require_group_auth_as_author( + &event(1, Vec::new()), + &GroupEventClass::NonGroup, + &GroupAuthContext::unauthenticated() + ) + .is_ok() + ); + } + fn event(kind: u32, tags: Vec<Tag>) -> Event { Event::new( EventId::new(&"0".repeat(64)).expect("id"),