tangle


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

commit 96256fdb4def721f05eaf66aade786c16dfd5b38
parent 3ac859cd9d435903105d0128edc5c4743df21b3b
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 04:18:24 -0700

groups: add strict group policy config

- Add GroupPolicyConfig with public_join and invites_enabled fields that default to strict false values.
- Reject enabled invite policy until a complete invite flow exists and reject unknown compatibility policy fields.
- Thread policy config through grouped runtime settings and the group write policy without changing public join enforcement yet.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.

Diffstat:
Mcrates/tangle_groups/src/lib.rs | 166++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/tangle_groups/src/policy.rs | 40+++++++++++++++++++++++-----------------
Mcrates/tangle_runtime/src/groups.rs | 10++++++----
Mcrates/tangle_test_support/src/lib.rs | 15++++++++++-----
4 files changed, 191 insertions(+), 40 deletions(-)

diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs @@ -153,6 +153,110 @@ impl Default for GroupRedactionConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GroupPolicyConfig { + #[serde(default)] + public_join: bool, + #[serde(default)] + invites_enabled: bool, +} + +impl GroupPolicyConfig { + pub fn strict() -> Self { + Self { + public_join: false, + invites_enabled: false, + } + } + + pub fn new(public_join: bool, invites_enabled: bool) -> Result<Self, GroupConfigError> { + let value = Self { + public_join, + invites_enabled, + }; + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<(), GroupConfigError> { + if self.invites_enabled { + return Err(GroupConfigError::invalid( + "groups.policy.invites_enabled is not supported until invite flow is implemented", + )); + } + Ok(()) + } + + pub fn public_join(&self) -> bool { + self.public_join + } + + pub fn invites_enabled(&self) -> bool { + self.invites_enabled + } +} + +impl Default for GroupPolicyConfig { + fn default() -> Self { + Self::strict() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupRuntimeSettingsConfig { + policy: GroupPolicyConfig, + redaction: GroupRedactionConfig, + limits: GroupLimitsConfig, +} + +impl GroupRuntimeSettingsConfig { + pub fn strict() -> Self { + Self { + policy: GroupPolicyConfig::strict(), + redaction: GroupRedactionConfig::strict(), + limits: GroupLimitsConfig::default(), + } + } + + pub fn new( + policy: GroupPolicyConfig, + redaction: GroupRedactionConfig, + limits: GroupLimitsConfig, + ) -> Result<Self, GroupConfigError> { + let value = Self { + policy, + redaction, + limits, + }; + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<(), GroupConfigError> { + self.policy.validate()?; + self.limits.validate() + } + + pub fn policy(&self) -> GroupPolicyConfig { + self.policy + } + + pub fn redaction(&self) -> GroupRedactionConfig { + self.redaction + } + + pub fn limits(&self) -> GroupLimitsConfig { + self.limits + } +} + +impl Default for GroupRuntimeSettingsConfig { + fn default() -> Self { + Self::strict() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub struct GroupLimitsConfig { #[serde(default = "default_max_group_id_bytes")] max_group_id_bytes: u16, @@ -246,8 +350,7 @@ pub struct GroupRuntimeConfig { relay_secret: Option<RelaySecret>, owner_pubkeys: Vec<PublicKeyHex>, admin_pubkeys: Vec<PublicKeyHex>, - redaction: GroupRedactionConfig, - limits: GroupLimitsConfig, + settings: GroupRuntimeSettingsConfig, } impl GroupRuntimeConfig { @@ -258,8 +361,7 @@ impl GroupRuntimeConfig { relay_secret: None, owner_pubkeys: Vec::new(), admin_pubkeys: Vec::new(), - redaction: GroupRedactionConfig::default(), - limits: GroupLimitsConfig::default(), + settings: GroupRuntimeSettingsConfig::default(), } } @@ -269,10 +371,9 @@ impl GroupRuntimeConfig { relay_secret: Option<RelaySecret>, owner_pubkeys: Vec<PublicKeyHex>, admin_pubkeys: Vec<PublicKeyHex>, - redaction: GroupRedactionConfig, - limits: GroupLimitsConfig, + settings: GroupRuntimeSettingsConfig, ) -> Result<Self, GroupConfigError> { - limits.validate()?; + settings.validate()?; if enabled && canonical_relay_url.is_none() { return Err(GroupConfigError::invalid( "groups.canonical_relay_url is required when groups are enabled", @@ -289,8 +390,7 @@ impl GroupRuntimeConfig { relay_secret, owner_pubkeys, admin_pubkeys, - redaction, - limits, + settings, }) } @@ -314,12 +414,16 @@ impl GroupRuntimeConfig { &self.admin_pubkeys } + pub fn policy(&self) -> GroupPolicyConfig { + self.settings.policy() + } + pub fn redaction(&self) -> GroupRedactionConfig { - self.redaction + self.settings.redaction() } pub fn limits(&self) -> GroupLimitsConfig { - self.limits + self.settings.limits() } } @@ -358,6 +462,8 @@ struct GroupRuntimeConfigDocument { #[serde(default)] admin_pubkeys: Vec<String>, #[serde(default)] + policy: GroupPolicyConfig, + #[serde(default)] redaction: GroupRedactionConfig, #[serde(default)] limits: GroupLimitsConfig, @@ -383,8 +489,7 @@ pub fn parse_group_runtime_config_json(raw: &str) -> Result<GroupRuntimeConfig, relay_secret, parse_pubkeys("groups.owner_pubkeys", document.owner_pubkeys)?, parse_pubkeys("groups.admin_pubkeys", document.admin_pubkeys)?, - document.redaction, - document.limits, + GroupRuntimeSettingsConfig::new(document.policy, document.redaction, document.limits)?, ) } @@ -456,7 +561,8 @@ fn default_max_outbox_replay_batch() -> u32 { #[cfg(test)] mod tests { use super::{ - CanonicalRelayUrl, GroupLimitsConfig, RelaySecret, parse_group_runtime_config_json, + CanonicalRelayUrl, GroupLimitsConfig, GroupPolicyConfig, RelaySecret, + parse_group_runtime_config_json, }; #[test] @@ -481,6 +587,7 @@ mod tests { "relay_secret": "{secret}", "owner_pubkeys": ["{owner}"], "admin_pubkeys": ["{admin}"], + "policy": {{"public_join": false, "invites_enabled": false}}, "redaction": {{"redact_private_tags": true, "redact_invite_codes": true}}, "limits": {{ "max_group_id_bytes": 64, @@ -501,6 +608,9 @@ mod tests { ); assert_eq!(config.owner_pubkeys().len(), 1); assert_eq!(config.admin_pubkeys().len(), 1); + assert_eq!(config.policy(), GroupPolicyConfig::strict()); + assert!(!config.policy().public_join()); + assert!(!config.policy().invites_enabled()); assert!(config.redaction().redact_private_tags()); assert!(config.redaction().redact_invite_codes()); assert_eq!(config.limits().max_group_id_bytes(), 64); @@ -517,6 +627,34 @@ mod tests { assert!(!config.enabled()); assert!(config.canonical_relay_url().is_none()); assert!(config.relay_secret().is_none()); + assert_eq!(config.policy(), GroupPolicyConfig::strict()); + } + + #[test] + fn group_policy_rejects_enabled_invites_until_invite_flow_exists() { + let error = parse_group_runtime_config_json( + r#"{"enabled": false, "policy": {"invites_enabled": true}}"#, + ) + .expect_err("invites"); + + assert_eq!( + error.message(), + "groups.policy.invites_enabled is not supported until invite flow is implemented" + ); + } + + #[test] + fn group_policy_rejects_compatibility_fields() { + let error = parse_group_runtime_config_json( + r#"{"enabled": false, "policy": {"compat_zooid_closed_means_restricted": true}}"#, + ) + .expect_err("compat"); + + assert!( + error + .message() + .contains("unknown field `compat_zooid_closed_means_restricted`") + ); } #[test] diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -2,11 +2,11 @@ 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, event_view::GroupEventView, - require_group_auth_as_author, resolve_capabilities, + GroupLifecycleState, GroupPolicyConfig, 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, event_view::GroupEventView, require_group_auth_as_author, resolve_capabilities, }; use tangle_protocol::PublicKeyHex; @@ -62,13 +62,19 @@ pub enum GroupWriteDecision { pub struct GroupWritePolicy<'a> { projection: &'a GroupProjection, authority: &'a GroupAuthority, + policy: GroupPolicyConfig, } impl<'a> GroupWritePolicy<'a> { - pub fn new(projection: &'a GroupProjection, authority: &'a GroupAuthority) -> Self { + pub fn new( + projection: &'a GroupProjection, + authority: &'a GroupAuthority, + policy: GroupPolicyConfig, + ) -> Self { Self { projection, authority, + policy, } } @@ -123,7 +129,7 @@ impl<'a> GroupWritePolicy<'a> { return self.check_create_group(event, group_id); } let group = self.require_active_group(group_id)?; - if kind == KIND_GROUP_CREATE_INVITE { + if kind == KIND_GROUP_CREATE_INVITE && !self.policy.invites_enabled() { return Err(GroupError::restricted( GroupErrorKind::MissingCapability, "invites not enabled", @@ -389,8 +395,8 @@ mod tests { use super::{GroupAuthority, GroupWriteDecision, GroupWritePolicy}; use crate::{ Capability, CapabilitySet, GroupAuthContext, GroupErrorKind, GroupEventClass, GroupId, - GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupProjection, GroupState, - KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_GROUP, + GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupPolicyConfig, GroupProjection, + GroupState, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_REMOVE_USER, MemberState, MemberStatus, ProjectedRoleDefinition, ProjectionOrderTuple, RoleDefinition, RoleName, StoreOffset, SupportedKinds, @@ -405,7 +411,7 @@ mod tests { let owner = pubkey("1"); let author = pubkey("2"); let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); 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(), @@ -451,7 +457,7 @@ mod tests { group_id: group_id.clone(), }; let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); let create = event(KIND_GROUP_CREATE_GROUP, owner.clone(), vec![h("Farm")]); assert_eq!( @@ -467,7 +473,7 @@ mod tests { .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 policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); let normal = event(1, owner.clone(), vec![h("Farm")]); assert_eq!( @@ -503,7 +509,7 @@ mod tests { ); put_member(&mut projection, "Farm", member.clone(), []); let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); assert_eq!( policy @@ -559,7 +565,7 @@ mod tests { ); put_member(&mut projection, "Farm", moderator.clone(), [moderator_role]); let authority = GroupAuthority::new([owner], [protected.clone()]); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); assert_eq!( policy @@ -610,7 +616,7 @@ mod tests { ); put_member(&mut projection, "Farm", member.clone(), []); let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); assert_eq!( policy @@ -661,7 +667,7 @@ mod tests { owner.clone(), ); let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); assert_eq!( policy @@ -687,7 +693,7 @@ mod tests { owner.clone(), ); let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); let invite = event(KIND_GROUP_CREATE_INVITE, owner.clone(), vec![h("Farm")]); let error = policy diff --git a/crates/tangle_runtime/src/groups.rs b/crates/tangle_runtime/src/groups.rs @@ -9,9 +9,9 @@ use tangle_crypto::RelaySigner; use tangle_groups::{ GroupAuthContext, GroupAuthority, GroupError, GroupErrorKind, GroupEventClass, GroupEventDeletion, GroupGeneratedEventBuilder, GroupId, GroupLimitsConfig, GroupOutbox, - GroupOutboxEffect, GroupOutboxKey, GroupOutboxPayload, GroupOutboxRecord, GroupProjection, - GroupReadDecision, GroupReadGate, GroupRuntimeConfig, GroupState, GroupTombstone, - KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_EDIT_METADATA, + GroupOutboxEffect, GroupOutboxKey, GroupOutboxPayload, GroupOutboxRecord, GroupPolicyConfig, + GroupProjection, GroupReadDecision, GroupReadGate, GroupRuntimeConfig, GroupState, + GroupTombstone, KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, MemberState, ProjectedRoleDefinition, ProjectionCheckpoint, StoreOffset, event_deletion_key, event_view::GroupEventView, group_current_key, @@ -28,6 +28,7 @@ pub(crate) struct GroupService { authority: GroupAuthority, projection: GroupProjection, outbox: GroupOutbox, + policy: GroupPolicyConfig, limits: GroupLimitsConfig, member_snapshot_cap: u32, } @@ -53,6 +54,7 @@ impl GroupService { ), projection: load_group_projection(store)?, outbox: load_group_outbox(store)?, + policy: config.policy(), limits: config.limits(), member_snapshot_cap: config.limits().max_member_list_pubkeys(), }; @@ -76,7 +78,7 @@ impl GroupService { class: &GroupEventClass, auth: &GroupAuthContext, ) -> Result<(), GroupError> { - tangle_groups::GroupWritePolicy::new(&self.projection, &self.authority) + tangle_groups::GroupWritePolicy::new(&self.projection, &self.authority, self.policy) .check_event(event, class, auth) .map(|_| ())?; self.check_runtime_write_constraints(store, event, class) diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs @@ -6,9 +6,10 @@ use k256::schnorr::{Signature, SigningKey}; use tangle_crypto::{RelaySigner, compute_event_id}; use tangle_groups::{ CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload, - GroupRedactionConfig, GroupRuntimeConfig, KIND_GROUP_CREATE_GROUP, 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, RelaySecret, + GroupPolicyConfig, GroupRedactionConfig, GroupRuntimeConfig, GroupRuntimeSettingsConfig, + KIND_GROUP_CREATE_GROUP, 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, RelaySecret, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -87,8 +88,12 @@ pub fn tangle_v2_group_config( Some(RelaySecret::from_hex(TANGLE_V2_RELAY_SECRET_HEX).map_err(|error| error.to_string())?), vec![owner.public_key()], admins.iter().map(|admin| admin.public_key()).collect(), - GroupRedactionConfig::strict(), - GroupLimitsConfig::default(), + GroupRuntimeSettingsConfig::new( + GroupPolicyConfig::strict(), + GroupRedactionConfig::strict(), + GroupLimitsConfig::default(), + ) + .map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string()) }