tangle


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

commit 3333bc18b3ccd380348d215ca26f23abf471f8e1
parent 96256fdb4def721f05eaf66aade786c16dfd5b38
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 04:29:41 -0700

groups: enforce public join policy

- Deny 9021 join requests by default through the strict group policy unless public_join is explicitly enabled.
- Keep duplicate joins and member leaves independent of public join while preserving non-enumerating join denial messages.
- Update runtime and acceptance tests so successful join flows opt into public_join=true and strict defaults reject joins.
- 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/policy.rs | 38+++++++++++++++++++++++++++++++-------
Mcrates/tangle_runtime/src/relay/core.rs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/tangle_runtime/tests/phase2_acceptance_targets.rs | 45+++++++++++++++++++++++++++++++++++++++------
4 files changed, 183 insertions(+), 24 deletions(-)

diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -233,7 +233,7 @@ impl<'a> GroupWritePolicy<'a> { "group member already exists", )); } - if group.metadata().closed() { + if group.metadata().closed() || !self.policy.public_join() { return Err(non_enumerating_group_error()); } Ok(GroupWriteDecision::Accept) @@ -616,10 +616,30 @@ mod tests { ); put_member(&mut projection, "Farm", member.clone(), []); let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); + let strict_policy = + GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); + let public_join_error = strict_policy + .check_event( + &event(KIND_GROUP_JOIN_REQUEST, joiner.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm"), + }, + &GroupAuthContext::new([joiner.clone()]), + ) + .expect_err("public join"); + assert_eq!(public_join_error.kind(), GroupErrorKind::GroupUnavailable); assert_eq!( - policy + public_join_error.prefixed_message(), + "restricted: group is unavailable" + ); + let public_policy = GroupWritePolicy::new( + &projection, + &authority, + GroupPolicyConfig::new(true, false).expect("policy"), + ); + assert_eq!( + public_policy .check_event( &event(KIND_GROUP_JOIN_REQUEST, joiner.clone(), vec![h("Farm")]), &GroupEventClass::Normal { @@ -627,11 +647,11 @@ mod tests { }, &GroupAuthContext::new([joiner]) ) - .expect("join"), + .expect("public join"), GroupWriteDecision::Accept ); assert_eq!( - policy + strict_policy .check_event( &event(KIND_GROUP_JOIN_REQUEST, member.clone(), vec![h("Farm")]), &GroupEventClass::Normal { @@ -644,7 +664,7 @@ mod tests { GroupErrorKind::DuplicateMember ); assert_eq!( - policy + strict_policy .check_event( &event(KIND_GROUP_LEAVE_REQUEST, member.clone(), vec![h("Farm")]), &GroupEventClass::Normal { @@ -667,7 +687,11 @@ mod tests { owner.clone(), ); let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); - let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); + let policy = GroupWritePolicy::new( + &projection, + &authority, + GroupPolicyConfig::new(true, false).expect("policy"), + ); assert_eq!( policy diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs @@ -935,12 +935,15 @@ mod tests { let mut relay = test_relay_with_groups( "base-relay-group-join", 4, - &enabled_groups_for_owner(&owner), + &enabled_groups_for_owner_with_public_join(&owner), ); let create = signed_group_create_event(7, "Farm"); - relay - .handle_event_with_auth(create, &authenticated_state(7)) - .expect("create"); + assert_accepted( + relay + .handle_event_with_auth(create.clone(), &authenticated_state(7)) + .expect("create"), + &create, + ); let join = signed_event_at( 8, KIND_GROUP_JOIN_REQUEST.into(), @@ -973,6 +976,37 @@ mod tests { } #[test] + fn group_join_requires_public_join_policy() { + let owner = signer(7).public_key().clone(); + let mut relay = test_relay_with_groups( + "base-relay-group-join-default-deny", + 4, + &enabled_groups_for_owner(&owner), + ); + let create = signed_group_create_event(7, "Farm"); + relay + .handle_event_with_auth(create, &authenticated_state(7)) + .expect("create"); + let join = signed_event_at( + 8, + KIND_GROUP_JOIN_REQUEST.into(), + vec![Tag::from_parts("h", &["Farm"]).expect("h")], + "", + 1_714_124_434, + ); + + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(join, &authenticated_state(8)) + .expect("join") + ), + "restricted: group is unavailable" + ); + assert_eq!(count_kind(&relay, KIND_GROUP_PUT_USER), 0); + } + + #[test] fn group_metadata_edit_replaces_generated_metadata_snapshot() { let owner = signer(7).public_key().clone(); let mut relay = test_relay_with_groups( @@ -1028,7 +1062,7 @@ mod tests { let mut relay = test_relay_with_groups( "base-relay-group-member-flow", 4, - &enabled_groups_for_owner(&owner), + &enabled_groups_for_owner_with_public_join(&owner), ); let owner_auth = authenticated_state(7); let member_auth = authenticated_state(8); @@ -1642,6 +1676,23 @@ mod tests { .expect("groups") } + fn enabled_groups_for_owner_with_public_join( + owner: &PublicKeyHex, + ) -> tangle_groups::GroupRuntimeConfig { + parse_group_runtime_config_json(&format!( + r#"{{ + "enabled": true, + "canonical_relay_url": "wss://relay.radroots.test", + "relay_secret": "{}", + "owner_pubkeys": ["{}"], + "policy": {{"public_join": true, "invites_enabled": false}} + }}"#, + "7".repeat(64), + owner.as_str() + )) + .expect("groups") + } + fn disabled_groups() -> tangle_groups::GroupRuntimeConfig { parse_group_runtime_config_json(r#"{"enabled": false}"#).expect("groups") } diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -4,7 +4,7 @@ use std::{fs, panic, path::PathBuf}; use tangle_crypto::{event_id_matches, verify_event_signature}; use tangle_groups::{ GroupId, GroupRuntimeConfig, KIND_GROUP_ADMINS, KIND_GROUP_DELETE_GROUP, KIND_GROUP_MEMBERS, - KIND_GROUP_METADATA, MemberStatus, + KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, MemberStatus, parse_group_runtime_config_json, }; use tangle_protocol::{ Event, Filter, RawEventJson, RelayMessage, SubscriptionId, Tag, UnixTimestamp, @@ -16,10 +16,11 @@ use tangle_runtime::{ }; use tangle_store_pocket::{PocketStoreConfig, PocketSyncPolicy}; use tangle_test_support::{ - FixtureKey, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, tangle_v2_delete_group_event, - tangle_v2_event, tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event, - tangle_v2_group_metadata_event, tangle_v2_join_event, tangle_v2_leave_event, - tangle_v2_put_user_event, tangle_v2_remove_user_event, + FixtureKey, TANGLE_V2_RELAY_SECRET_HEX, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, + tangle_v2_delete_group_event, tangle_v2_event, tangle_v2_group_config, + tangle_v2_group_create_event, tangle_v2_group_event, tangle_v2_group_metadata_event, + tangle_v2_join_event, tangle_v2_leave_event, tangle_v2_put_user_event, + tangle_v2_remove_user_event, }; #[test] @@ -146,7 +147,8 @@ fn auth_integration_covers_challenge_edges() { #[test] fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() { let config = test_store_config("group-flows"); - let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay"); + let groups = group_config_with_public_join(); + let mut relay = BaseRelay::open_with_groups(&config, 8, &groups).expect("relay"); let owner_auth = authenticated(FixtureKey::Owner); let admin_auth = authenticated(FixtureKey::Admin); let member_auth = authenticated(FixtureKey::Member); @@ -268,6 +270,39 @@ fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() { } #[test] +fn group_join_requests_are_denied_by_default() { + let config = test_store_config("group-public-join-default"); + let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay"); + let owner_auth = authenticated(FixtureKey::Owner); + let outsider_auth = authenticated(FixtureKey::Outsider); + let create = tangle_v2_group_create_event(FixtureKey::Owner, "Farm", 1, &[]).expect("create"); + assert_accepted( + relay + .handle_event_with_auth(create.clone(), &owner_auth) + .expect("create"), + &create, + ); + let join = tangle_v2_join_event(FixtureKey::Outsider, "Farm", 2).expect("join"); + + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(join, &outsider_auth) + .expect("join") + ), + "restricted: group is unavailable" + ); + assert!( + relay + .group_projection() + .expect("projection") + .member(&group("Farm"), &FixtureKey::Outsider.public_key()) + .is_none() + ); + assert_eq!(count_kind(&relay, KIND_GROUP_PUT_USER), 0); +} + +#[test] fn metadata_flags_and_read_privacy_cover_req_count_and_fanout() { let config = test_store_config("privacy-flags"); let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay"); @@ -693,6 +728,22 @@ fn group_config() -> GroupRuntimeConfig { tangle_v2_group_config(FixtureKey::Owner, &[FixtureKey::Admin]).expect("groups") } +fn group_config_with_public_join() -> GroupRuntimeConfig { + parse_group_runtime_config_json(&format!( + r#"{{ + "enabled": true, + "canonical_relay_url": "{TANGLE_V2_RELAY_URL}", + "relay_secret": "{TANGLE_V2_RELAY_SECRET_HEX}", + "owner_pubkeys": ["{}"], + "admin_pubkeys": ["{}"], + "policy": {{"public_join": true, "invites_enabled": false}} + }}"#, + FixtureKey::Owner.public_key().as_str(), + FixtureKey::Admin.public_key().as_str() + )) + .expect("groups") +} + fn authenticated(key: FixtureKey) -> BaseAuthState { let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60, 600).expect("auth"); let mut auth = issue_challenge(auth, "challenge-a", 100); diff --git a/crates/tangle_runtime/tests/phase2_acceptance_targets.rs b/crates/tangle_runtime/tests/phase2_acceptance_targets.rs @@ -10,9 +10,10 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tangle_groups::{ - GroupAuthority, GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupProjection, - GroupReadDecision, GroupReadGate, GroupState, KIND_GROUP_ADMINS, KIND_GROUP_MEMBERS, - KIND_GROUP_METADATA, ProjectionOrderTuple, StoreOffset, SupportedKinds, + GroupAuthContext, GroupAuthority, GroupErrorKind, GroupEventClass, GroupId, GroupMetadata, + GroupMetadataFlags, GroupMetadataText, GroupPolicyConfig, GroupProjection, GroupReadDecision, + GroupReadGate, GroupState, GroupWritePolicy, KIND_GROUP_ADMINS, KIND_GROUP_JOIN_REQUEST, + KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, ProjectionOrderTuple, StoreOffset, SupportedKinds, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, RelayMessage, SignatureHex, Tag, UnixTimestamp, @@ -323,11 +324,29 @@ fn private_but_not_hidden_group_metadata_remains_visible() { } #[test] -#[ignore = "phase2 target: public join policy"] fn public_join_defaults_false() { - pending( - "group join requests must be denied by default unless public join or invite flow allows them", + let owner = phase2_pubkey("1"); + let joiner = phase2_pubkey("2"); + let projection = phase2_projection_with_group( + "Farm", + phase2_metadata(false, false, false, false), + owner.clone(), ); + let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict()); + let join = phase2_group_event(KIND_GROUP_JOIN_REQUEST, "Farm", joiner.clone()); + let error = policy + .check_event( + &join, + &GroupEventClass::Normal { + group_id: GroupId::new("Farm").expect("group"), + }, + &GroupAuthContext::new([joiner]), + ) + .expect_err("join"); + + assert_eq!(error.kind(), GroupErrorKind::GroupUnavailable); + assert_eq!(error.prefixed_message(), "restricted: group is unavailable"); } #[test] @@ -588,6 +607,20 @@ fn phase2_snapshot_event(kind: u32, group_id: &str) -> Event { ) } +fn phase2_group_event(kind: u32, group_id: &str, author: PublicKeyHex) -> Event { + Event::new( + phase2_event_id("02"), + UnsignedEvent::new( + author, + UnixTimestamp::new(2), + Kind::new(kind.into()).expect("kind"), + vec![Tag::from_parts("h", &[group_id]).expect("h")], + "", + ), + SignatureHex::new(&"3".repeat(128)).expect("sig"), + ) +} + fn phase2_pubkey(suffix: &str) -> PublicKeyHex { PublicKeyHex::new(&suffix.repeat(64)).expect("pubkey") }