commit 1c358170ac329f299e2e8f28804588e50bcfde0c
parent 5c4369d1afd9585fc22d36818efcd43bc3ece72e
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 05:58:41 -0700
groups: generate admin snapshots for role changes
- Compare membership relay-override admin state before and after accepted membership writes.
- Enqueue admin-list snapshots alongside member snapshots when role changes alter the derived admin set.
- Cover relay-override promotion and demotion through the live relay path and stored generated outbox records.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.
Diffstat:
2 files changed, 248 insertions(+), 10 deletions(-)
diff --git a/crates/tangle_runtime/src/groups.rs b/crates/tangle_runtime/src/groups.rs
@@ -16,8 +16,8 @@ use tangle_groups::{
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,
+ KIND_GROUP_MEMBERS, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, MemberState, MemberStatus,
+ ProjectedRoleDefinition, ProjectionCheckpoint, RoleName, StoreOffset, event_deletion_key,
event_view::GroupEventView, group_current_key, member_current_key, projection_checkpoint_key,
rebuild_group_projection, role_current_key, tombstone_key,
};
@@ -159,12 +159,14 @@ impl GroupService {
class: &GroupEventClass,
store_offset: StoreOffset,
) -> Result<Vec<StoreOffset>, BaseRelayError> {
+ let before_membership_admin =
+ membership_admin_snapshot_state(&self.projection, event, class)?;
self.projection
.apply_canonical_event(event, store_offset, self.limits)?;
if let Some(group_id) = class_group_id(class) {
self.persist_group_projection(store, group_id)?;
}
- for record in self.plan_outbox_records(event, class)? {
+ for record in self.plan_outbox_records(event, class, before_membership_admin)? {
let inserted = self.outbox.merge_idempotent(record.clone())?;
if inserted {
persist_outbox_record(store, &record)?;
@@ -180,6 +182,7 @@ impl GroupService {
&self,
event: &Event,
class: &GroupEventClass,
+ before_membership_admin: Option<bool>,
) -> Result<Vec<GroupOutboxRecord>, GroupError> {
plan_group_outbox_records(
event,
@@ -187,6 +190,7 @@ impl GroupService {
&self.projection,
&self.authority,
self.member_snapshot_cap,
+ before_membership_admin,
)
}
@@ -201,6 +205,8 @@ impl GroupService {
events.sort_by_key(CanonicalGroupEvent::tuple);
for item in events {
let class = tangle_groups::classify_group_event(item.event(), self.limits)?;
+ let before_membership_admin =
+ membership_admin_snapshot_state(&projection, item.event(), &class)?;
projection.apply_canonical_event(item.event(), item.store_offset(), self.limits)?;
if item.event().unsigned().pubkey() == &relay_pubkey {
continue;
@@ -211,6 +217,7 @@ impl GroupService {
&projection,
&self.authority,
self.member_snapshot_cap,
+ before_membership_admin,
)? {
let inserted = self.outbox.merge_idempotent(record.clone())?;
if inserted {
@@ -359,6 +366,7 @@ fn plan_group_outbox_records(
projection: &GroupProjection,
authority: &GroupAuthority,
member_snapshot_cap: u32,
+ before_membership_admin: Option<bool>,
) -> Result<Vec<GroupOutboxRecord>, GroupError> {
let created_at = event.unsigned().created_at();
match class {
@@ -394,9 +402,15 @@ fn plan_group_outbox_records(
GroupGeneratedEventBuilder::metadata_snapshot_payload(group, created_at)?,
)])
}
- KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => {
- member_snapshot_record(event, group_id, projection, created_at, member_snapshot_cap)
- }
+ KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => member_snapshot_records(
+ event,
+ group_id,
+ projection,
+ authority,
+ created_at,
+ member_snapshot_cap,
+ before_membership_admin,
+ ),
_ => Ok(Vec::new()),
},
GroupEventClass::Normal { group_id } => match event.unsigned().kind().as_u32() {
@@ -430,6 +444,35 @@ fn plan_group_outbox_records(
}
}
+fn member_snapshot_records(
+ event: &Event,
+ group_id: &GroupId,
+ projection: &GroupProjection,
+ authority: &GroupAuthority,
+ created_at: UnixTimestamp,
+ member_snapshot_cap: u32,
+ before_membership_admin: Option<bool>,
+) -> Result<Vec<GroupOutboxRecord>, GroupError> {
+ let mut records =
+ member_snapshot_record(event, group_id, projection, created_at, member_snapshot_cap)?;
+ if let Some(before) = before_membership_admin {
+ let target = membership_target_pubkey(event)?;
+ let after = member_is_relay_override_admin(projection, group_id, &target);
+ if before != after {
+ records.push(pending_record(
+ event,
+ GroupOutboxEffect::AdminListSnapshot,
+ group_id,
+ None,
+ GroupGeneratedEventBuilder::admin_list_snapshot_payload(
+ group_id, projection, authority, created_at,
+ )?,
+ ));
+ }
+ }
+ Ok(records)
+}
+
fn member_snapshot_record(
event: &Event,
group_id: &GroupId,
@@ -467,6 +510,63 @@ fn member_snapshot_record(
}])
}
+fn membership_admin_snapshot_state(
+ projection: &GroupProjection,
+ event: &Event,
+ class: &GroupEventClass,
+) -> Result<Option<bool>, GroupError> {
+ match class {
+ GroupEventClass::Moderation { kind, group_id }
+ if matches!(kind.as_u32(), KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER) =>
+ {
+ let target = membership_target_pubkey(event)?;
+ Ok(Some(member_is_relay_override_admin(
+ projection, group_id, &target,
+ )))
+ }
+ _ => Ok(None),
+ }
+}
+
+fn member_is_relay_override_admin(
+ projection: &GroupProjection,
+ group_id: &GroupId,
+ pubkey: &PublicKeyHex,
+) -> bool {
+ projection
+ .member(group_id, pubkey)
+ .filter(|member| member.status() == MemberStatus::Member)
+ .is_some_and(|member| {
+ member
+ .roles()
+ .contains(&RoleName::permanent_relay_override())
+ })
+}
+
+fn membership_target_pubkey(event: &Event) -> Result<PublicKeyHex, GroupError> {
+ for tag in event.unsigned().tags() {
+ if tag.values().first().is_none_or(|name| name != "p") {
+ continue;
+ }
+ let Some((_, value)) = tag.indexed_pair() else {
+ return Err(GroupError::invalid(
+ GroupErrorKind::MalformedTargetTag,
+ "malformed p target tag",
+ ));
+ };
+ return PublicKeyHex::new(value).map_err(|reason| {
+ GroupError::invalid(
+ GroupErrorKind::MalformedTargetTag,
+ format!("malformed p target tag: {reason}"),
+ )
+ });
+ }
+ Err(GroupError::invalid(
+ GroupErrorKind::MissingTargetTag,
+ "missing p target tag",
+ ))
+}
+
fn pending_record(
event: &Event,
effect: GroupOutboxEffect,
diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs
@@ -6,8 +6,8 @@ use tangle_groups::{
GroupId, GroupOutboxRecord, GroupOutboxStatus, GroupRuntimeConfig, KIND_GROUP_ADMINS,
KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS,
KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, MemberStatus, NIP29_RELAY_GENERATED_KIND_VALUES,
- ProjectionCheckpoint, StoreOffset, member_current_key, parse_group_runtime_config_json,
- projection_checkpoint_key,
+ PERMANENT_RELAY_OVERRIDE_ROLE, ProjectionCheckpoint, StoreOffset, member_current_key,
+ parse_group_runtime_config_json, projection_checkpoint_key,
};
use tangle_protocol::{
Event, Filter, RawEventJson, RelayMessage, SubscriptionId, Tag, UnixTimestamp,
@@ -26,8 +26,8 @@ use tangle_test_support::{
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,
+ tangle_v2_group_tag, tangle_v2_join_event, tangle_v2_leave_event, tangle_v2_pubkey_tag,
+ tangle_v2_put_user_event, tangle_v2_remove_user_event, tangle_v2_tag,
};
#[test]
@@ -277,6 +277,78 @@ fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() {
}
#[test]
+fn relay_override_role_changes_generate_admin_snapshots() {
+ let config = test_store_config("role-admin-snapshots");
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let owner_auth = authenticated(FixtureKey::Owner);
+ let admin_auth = authenticated(FixtureKey::Admin);
+ let member = FixtureKey::Member.public_key().as_str().to_owned();
+ let owner = FixtureKey::Owner.public_key().as_str().to_owned();
+ let admin = FixtureKey::Admin.public_key().as_str().to_owned();
+
+ accept_group_create(&mut relay, "RoleFarm", &[], 1, &owner_auth);
+ assert_eq!(
+ stored_event_ids_for_kind(&config, KIND_GROUP_ADMINS).len(),
+ 1
+ );
+
+ let promote = tangle_v2_put_user_event_with_roles(
+ FixtureKey::Admin,
+ "RoleFarm",
+ FixtureKey::Member,
+ 2,
+ &[PERMANENT_RELAY_OVERRIDE_ROLE],
+ );
+ assert_accepted(
+ relay
+ .handle_event_with_auth(promote.clone(), &admin_auth)
+ .expect("promote"),
+ &promote,
+ );
+ assert!(
+ relay
+ .group_projection()
+ .expect("projection")
+ .member(&group("RoleFarm"), &FixtureKey::Member.public_key())
+ .expect("member")
+ .roles()
+ .iter()
+ .any(|role| role.as_str() == PERMANENT_RELAY_OVERRIDE_ROLE)
+ );
+ assert_eq!(outbox_status_counts(&config).stored, 4);
+ assert_eq!(
+ stored_event_ids_for_kind(&config, KIND_GROUP_ADMINS).len(),
+ 2
+ );
+ assert_eq!(
+ latest_admin_snapshot_pubkeys(&mut relay, "RoleFarm"),
+ sorted_strings([owner.clone(), admin.clone(), member.clone()])
+ );
+
+ let demote = tangle_v2_put_user_event_with_roles(
+ FixtureKey::Admin,
+ "RoleFarm",
+ FixtureKey::Member,
+ 3,
+ &[],
+ );
+ assert_accepted(
+ relay
+ .handle_event_with_auth(demote.clone(), &admin_auth)
+ .expect("demote"),
+ &demote,
+ );
+ assert_eq!(
+ stored_event_ids_for_kind(&config, KIND_GROUP_ADMINS).len(),
+ 3
+ );
+ assert_eq!(
+ latest_admin_snapshot_pubkeys(&mut relay, "RoleFarm"),
+ sorted_strings([owner, admin])
+ );
+}
+
+#[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");
@@ -1204,6 +1276,72 @@ fn accept_group_create(
);
}
+fn tangle_v2_put_user_event_with_roles(
+ actor: FixtureKey,
+ group_id: &str,
+ target: FixtureKey,
+ created_at: u64,
+ roles: &[&str],
+) -> Event {
+ let mut tags = vec![
+ tangle_v2_group_tag(group_id).expect("group tag"),
+ tangle_v2_pubkey_tag(target).expect("pubkey tag"),
+ ];
+ for role in roles {
+ tags.push(tangle_v2_tag("role", &[*role]).expect("role tag"));
+ }
+ tangle_v2_event(actor, created_at, KIND_GROUP_PUT_USER.into(), tags, "").expect("put user")
+}
+
+fn latest_admin_snapshot_pubkeys(relay: &mut BaseRelay, group_id: &str) -> Vec<String> {
+ let mut events = query_events(
+ relay,
+ "admin-snapshots",
+ vec![filter_group_tag(KIND_GROUP_ADMINS, "d", group_id)],
+ );
+ events.sort_by_key(|event| (event.unsigned().created_at(), event.id().clone()));
+ let latest = events.last().expect("admin snapshot");
+ let mut pubkeys = latest
+ .unsigned()
+ .tags()
+ .iter()
+ .filter_map(|tag| match tag.values() {
+ [name, pubkey, ..] if name == "p" => Some(pubkey.clone()),
+ _ => None,
+ })
+ .collect::<Vec<_>>();
+ pubkeys.sort();
+ pubkeys
+}
+
+fn query_events(relay: &mut BaseRelay, subscription_id: &str, filters: Vec<Filter>) -> Vec<Event> {
+ let subscription_id = subscription(subscription_id);
+ let messages = relay
+ .handle_req(subscription_id.clone(), filters)
+ .expect("query");
+ let mut events = Vec::new();
+ for message in messages {
+ match message {
+ RelayMessage::Event {
+ subscription_id: actual,
+ event,
+ } => {
+ assert_eq!(actual, subscription_id);
+ events.push(event);
+ }
+ RelayMessage::Eose(actual) => assert_eq!(actual, subscription_id),
+ value => panic!("expected event or EOSE, got {value:?}"),
+ }
+ }
+ events
+}
+
+fn sorted_strings(values: impl IntoIterator<Item = String>) -> Vec<String> {
+ let mut values = values.into_iter().collect::<Vec<_>>();
+ values.sort();
+ values
+}
+
fn final_group_name_for_order(name: &str, edits: [&Event; 2]) -> String {
let config = test_store_config(name);
let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");