commit 194529f5d21901c3e6bc9f7a34fb3f2c1da52b6b
parent 80f7feb411ef94c9f08cac71f0c3813576fbce20
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 13:34:04 -0700
logging: audit group moderation decisions
- Add structured group moderation audit events with stable action and result fields.
- Emit accepted and rejected audit logs from group write validation paths.
- Redact group identifiers while avoiding event content, invite codes, and relay secrets.
- Cover required action families, target counts, field presence, and sensitive-value exclusion.
Diffstat:
2 files changed, 350 insertions(+), 4 deletions(-)
diff --git a/crates/tangle_runtime/src/logging.rs b/crates/tangle_runtime/src/logging.rs
@@ -5,7 +5,13 @@ use crate::{
errors::BaseRelayError,
};
use std::{fmt, net::IpAddr, net::SocketAddr};
-use tangle_protocol::{EventId, SubscriptionId, UnixTimestamp};
+use tangle_groups::{
+ GroupEventClass, KIND_GROUP_ADMINS, 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_MEMBERS, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER,
+ KIND_GROUP_REMOVE_USER,
+};
+use tangle_protocol::{Event, EventId, SubscriptionId, UnixTimestamp};
use tracing_subscriber::EnvFilter;
pub const TANGLE_LOG_REDACTED: &str = "<redacted>";
@@ -235,10 +241,131 @@ pub fn log_event_stored(event_id: &EventId, stored_offsets: usize, total_stored_
);
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum TangleModerationAuditResult {
+ Accepted,
+ Rejected,
+}
+
+impl TangleModerationAuditResult {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Accepted => "accepted",
+ Self::Rejected => "rejected",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct TangleModerationAuditEntry {
+ action_family: &'static str,
+ result: &'static str,
+ event_id: String,
+ actor_pubkey: String,
+ event_kind: u32,
+ target_count: usize,
+ generated_state_rejection: bool,
+}
+
+impl TangleModerationAuditEntry {
+ pub(crate) fn new(
+ event: &Event,
+ class: &GroupEventClass,
+ result: TangleModerationAuditResult,
+ ) -> Option<Self> {
+ let action_family = moderation_audit_action_family(event, class)?;
+ let generated_state_rejection = matches!(
+ (class, result),
+ (
+ GroupEventClass::RelayGeneratedSnapshot { .. },
+ TangleModerationAuditResult::Rejected
+ )
+ );
+ Some(Self {
+ action_family,
+ result: result.as_str(),
+ event_id: event.id().as_str().to_owned(),
+ actor_pubkey: event.unsigned().pubkey().as_str().to_owned(),
+ event_kind: event.unsigned().kind().as_u32(),
+ target_count: moderation_target_count(event, action_family),
+ generated_state_rejection,
+ })
+ }
+}
+
+pub(crate) fn log_group_moderation_audit(
+ event: &Event,
+ class: &GroupEventClass,
+ result: TangleModerationAuditResult,
+) {
+ let Some(entry) = TangleModerationAuditEntry::new(event, class, result) else {
+ return;
+ };
+ tracing::info!(
+ event = "group_moderation_audit",
+ action_family = entry.action_family,
+ result = entry.result,
+ event_id = entry.event_id,
+ actor_pubkey = entry.actor_pubkey,
+ event_kind = entry.event_kind,
+ target_count = entry.target_count,
+ group_id = TANGLE_LOG_REDACTED,
+ group_id_redacted = true,
+ generated_state_rejection = entry.generated_state_rejection,
+ "tangle group moderation audit"
+ );
+}
+
pub fn sanitize_error_message(config: &BaseRelayRuntimeConfig, message: impl AsRef<str>) -> String {
TangleLogRedactor::from_runtime_config(config).redact(message)
}
+fn moderation_audit_action_family(event: &Event, class: &GroupEventClass) -> Option<&'static str> {
+ match class {
+ GroupEventClass::Moderation { kind, .. } => match kind.as_u32() {
+ KIND_GROUP_CREATE_GROUP => Some("group_create"),
+ KIND_GROUP_EDIT_METADATA => Some("metadata"),
+ KIND_GROUP_DELETE_GROUP => Some("delete_group"),
+ KIND_GROUP_DELETE_EVENT => Some("delete_event"),
+ KIND_GROUP_PUT_USER => Some("put_user"),
+ KIND_GROUP_REMOVE_USER => Some("remove_user"),
+ _ => None,
+ },
+ GroupEventClass::Normal { .. } => match event.unsigned().kind().as_u32() {
+ KIND_GROUP_JOIN_REQUEST => Some("join"),
+ KIND_GROUP_LEAVE_REQUEST => Some("leave"),
+ _ => None,
+ },
+ GroupEventClass::RelayGeneratedSnapshot { kind, .. } => match kind.as_u32() {
+ KIND_GROUP_METADATA => Some("metadata"),
+ KIND_GROUP_ADMINS => Some("admins"),
+ KIND_GROUP_MEMBERS => Some("members"),
+ _ => None,
+ },
+ GroupEventClass::NonGroup => None,
+ }
+}
+
+fn moderation_target_count(event: &Event, action_family: &str) -> usize {
+ let target_tag = match action_family {
+ "put_user" | "remove_user" | "members" => Some("p"),
+ "delete_event" => Some("e"),
+ _ => None,
+ };
+ let Some(target_tag) = target_tag else {
+ return 0;
+ };
+ event
+ .unsigned()
+ .tags()
+ .iter()
+ .filter(|tag| {
+ tag.indexed_pair()
+ .is_some_and(|(name, _)| name == target_tag)
+ })
+ .count()
+}
+
fn relay_secret_log_value(config: &BaseRelayRuntimeConfig) -> &'static str {
if config.groups().relay_secret().is_some() {
TANGLE_LOG_REDACTED
@@ -256,14 +383,26 @@ fn optional_ip(peer_ip: Option<IpAddr>) -> String {
#[cfg(test)]
mod tests {
use super::{
- TANGLE_LOG_REDACTED, TangleLogRedactor, TangleRuntimeLogSummary, log_runtime_config_loaded,
- sanitize_error_message,
+ TANGLE_LOG_REDACTED, TangleLogRedactor, TangleModerationAuditEntry,
+ TangleModerationAuditResult, TangleRuntimeLogSummary, log_group_moderation_audit,
+ log_runtime_config_loaded, sanitize_error_message,
};
use crate::config::parse_base_relay_runtime_config_json;
use std::{
io,
sync::{Arc, Mutex},
};
+ use tangle_groups::{
+ GroupEventClass, GroupLimitsConfig, KIND_GROUP_ADMINS, KIND_GROUP_JOIN_REQUEST,
+ KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, classify_group_event,
+ };
+ use tangle_test_support::{
+ FixtureKey, tangle_v2_address_group_tag, tangle_v2_delete_event_event,
+ tangle_v2_delete_group_event, tangle_v2_event, tangle_v2_group_create_event,
+ tangle_v2_group_event, tangle_v2_group_metadata_event, tangle_v2_group_tag,
+ tangle_v2_join_event, tangle_v2_leave_event, tangle_v2_put_user_event,
+ tangle_v2_remove_user_event, tangle_v2_tag,
+ };
#[test]
fn log_redactor_removes_configured_relay_secret() {
@@ -315,6 +454,189 @@ mod tests {
assert!(!output.contains(&secret));
}
+ #[test]
+ fn group_moderation_audit_entries_cover_required_action_families_and_target_counts() {
+ let target = tangle_v2_group_event(FixtureKey::Member, "AuditFarm", 10, 1, "target")
+ .expect("target");
+ let cases = [
+ (
+ tangle_v2_group_create_event(FixtureKey::Owner, "AuditFarm", 11, &[])
+ .expect("create"),
+ "group_create",
+ 0,
+ false,
+ ),
+ (
+ tangle_v2_group_metadata_event(
+ FixtureKey::Owner,
+ "AuditFarm",
+ "Audit Farm",
+ 12,
+ &[],
+ )
+ .expect("metadata"),
+ "metadata",
+ 0,
+ false,
+ ),
+ (
+ tangle_v2_put_user_event(FixtureKey::Owner, "AuditFarm", FixtureKey::Member, 13)
+ .expect("put"),
+ "put_user",
+ 1,
+ false,
+ ),
+ (
+ tangle_v2_remove_user_event(FixtureKey::Owner, "AuditFarm", FixtureKey::Member, 14)
+ .expect("remove"),
+ "remove_user",
+ 1,
+ false,
+ ),
+ (
+ tangle_v2_delete_event_event(FixtureKey::Owner, "AuditFarm", &target, 15)
+ .expect("delete event"),
+ "delete_event",
+ 1,
+ false,
+ ),
+ (
+ tangle_v2_delete_group_event(FixtureKey::Owner, "AuditFarm", 16)
+ .expect("delete group"),
+ "delete_group",
+ 0,
+ false,
+ ),
+ (
+ tangle_v2_join_event(FixtureKey::Member, "AuditFarm", 17).expect("join"),
+ "join",
+ 0,
+ false,
+ ),
+ (
+ tangle_v2_leave_event(FixtureKey::Member, "AuditFarm", 18).expect("leave"),
+ "leave",
+ 0,
+ false,
+ ),
+ (
+ tangle_v2_event(
+ FixtureKey::Owner,
+ 19,
+ KIND_GROUP_METADATA.into(),
+ vec![tangle_v2_address_group_tag("AuditFarm").expect("d")],
+ "",
+ )
+ .expect("generated metadata"),
+ "metadata",
+ 0,
+ true,
+ ),
+ (
+ tangle_v2_event(
+ FixtureKey::Owner,
+ 20,
+ KIND_GROUP_ADMINS.into(),
+ vec![tangle_v2_address_group_tag("AuditFarm").expect("d")],
+ "",
+ )
+ .expect("generated admins"),
+ "admins",
+ 0,
+ true,
+ ),
+ (
+ tangle_v2_event(
+ FixtureKey::Owner,
+ 21,
+ KIND_GROUP_MEMBERS.into(),
+ vec![
+ tangle_v2_address_group_tag("AuditFarm").expect("d"),
+ tangle_v2_tag("p", &[FixtureKey::Member.public_key().as_str()]).expect("p"),
+ ],
+ "",
+ )
+ .expect("generated members"),
+ "members",
+ 1,
+ true,
+ ),
+ ];
+
+ for (event, action_family, target_count, generated_state_rejection) in cases {
+ let class = classify_group_event(&event, GroupLimitsConfig::default()).expect("class");
+ let result = if matches!(class, GroupEventClass::RelayGeneratedSnapshot { .. }) {
+ TangleModerationAuditResult::Rejected
+ } else {
+ TangleModerationAuditResult::Accepted
+ };
+ let entry =
+ TangleModerationAuditEntry::new(&event, &class, result).expect("audit entry");
+
+ assert_eq!(entry.action_family, action_family);
+ assert_eq!(entry.target_count, target_count);
+ assert_eq!(entry.generated_state_rejection, generated_state_rejection);
+ assert_eq!(entry.result, result.as_str());
+ }
+ }
+
+ #[test]
+ fn group_moderation_audit_log_redacts_group_content_invite_and_secret_values() {
+ let secret = "7".repeat(64);
+ let event = tangle_v2_event(
+ FixtureKey::Member,
+ 31,
+ KIND_GROUP_JOIN_REQUEST.into(),
+ vec![
+ tangle_v2_group_tag("SecretFarm").expect("h"),
+ tangle_v2_tag("code", &["invite-super-secret"]).expect("code"),
+ ],
+ "secret-content",
+ )
+ .expect("join");
+ let class = classify_group_event(&event, GroupLimitsConfig::default()).expect("class");
+ let writer = CapturedWriter::default();
+ let subscriber = tracing_subscriber::fmt()
+ .json()
+ .with_writer(writer.clone())
+ .with_max_level(tracing::Level::INFO)
+ .finish();
+
+ tracing::subscriber::with_default(subscriber, || {
+ log_group_moderation_audit(&event, &class, TangleModerationAuditResult::Rejected);
+ });
+
+ let output = writer.output();
+ assert!(output.contains(r#""event":"group_moderation_audit""#));
+ assert!(output.contains(r#""action_family":"join""#));
+ assert!(output.contains(r#""result":"rejected""#));
+ assert!(output.contains(r#""event_id":"#));
+ assert!(output.contains(event.id().as_str()));
+ assert!(output.contains(r#""actor_pubkey":"#));
+ assert!(output.contains(event.unsigned().pubkey().as_str()));
+ assert!(output.contains(r#""event_kind":9021"#));
+ assert!(output.contains(r#""target_count":0"#));
+ assert!(output.contains(r#""group_id":"<redacted>""#));
+ assert!(output.contains(r#""group_id_redacted":true"#));
+ assert!(output.contains(r#""generated_state_rejection":false"#));
+ assert!(!output.contains("SecretFarm"));
+ assert!(!output.contains("secret-content"));
+ assert!(!output.contains("invite-super-secret"));
+ assert!(!output.contains(&secret));
+ }
+
+ #[test]
+ fn group_moderation_audit_ignores_non_requested_group_event_kinds() {
+ let event =
+ tangle_v2_group_event(FixtureKey::Member, "AuditFarm", 41, 1, "normal").expect("event");
+ let class = classify_group_event(&event, GroupLimitsConfig::default()).expect("class");
+
+ assert!(
+ TangleModerationAuditEntry::new(&event, &class, TangleModerationAuditResult::Accepted)
+ .is_none()
+ );
+ }
+
#[derive(Clone, Default)]
struct CapturedWriter {
inner: Arc<Mutex<Vec<u8>>>,
diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs
@@ -1,5 +1,6 @@
use crate::errors::{BaseRelayError, ok_accepted, ok_rejected};
use crate::groups::GroupService;
+use crate::logging::{self, TangleModerationAuditResult};
use crate::ops::BaseRelayReadinessState;
use crate::pocket_conversion::{
pocket_event_id, pocket_event_to_tangle, tangle_event_to_pocket, tangle_filter_to_pocket,
@@ -12,7 +13,7 @@ use std::{cell::RefCell, collections::BTreeSet};
use tangle_crypto::verify_event_signature;
use tangle_groups::{
GroupAuthContext, GroupEventClass, GroupEventView, GroupProjection, GroupRuntimeConfig,
- StoreOffset, validate_client_group_event_structure,
+ StoreOffset, classify_group_event, validate_client_group_event_structure,
};
use tangle_protocol::{ClientMessage, Event, Filter, RelayMessage, SubscriptionId, UnixTimestamp};
use tangle_store_pocket::{PocketScreenResult, PocketStoreConfig, PocketStoreHandle};
@@ -634,9 +635,17 @@ impl BaseRelay {
.as_ref()
.map(GroupService::limits)
.unwrap_or_default();
+ let audit_class = classify_group_event(&event, group_limits).ok();
let class = match validate_client_group_event_structure(&event, group_limits) {
Ok(class) => class,
Err(error) => {
+ if let Some(class) = audit_class.as_ref() {
+ logging::log_group_moderation_audit(
+ &event,
+ class,
+ TangleModerationAuditResult::Rejected,
+ );
+ }
return Ok(BaseRelayEventWrite::unstored(ok_rejected(
event_id,
error.prefixed_message(),
@@ -645,17 +654,32 @@ impl BaseRelay {
};
if !matches!(class, GroupEventClass::NonGroup) {
let Some(groups) = self.groups.as_ref() else {
+ logging::log_group_moderation_audit(
+ &event,
+ &class,
+ TangleModerationAuditResult::Rejected,
+ );
return Ok(BaseRelayEventWrite::unstored(ok_rejected(
event_id,
"blocked: NIP-29 group events are not accepted before group service".to_owned(),
)));
};
if let Err(error) = groups.check_event(&self.store, &event, &class, auth) {
+ logging::log_group_moderation_audit(
+ &event,
+ &class,
+ TangleModerationAuditResult::Rejected,
+ );
return Ok(BaseRelayEventWrite::unstored(ok_rejected(
event_id,
error.prefixed_message(),
)));
}
+ logging::log_group_moderation_audit(
+ &event,
+ &class,
+ TangleModerationAuditResult::Accepted,
+ );
}
if event.unsigned().kind().is_ephemeral() {
return Ok(BaseRelayEventWrite::unstored(ok_accepted(