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:
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"),