Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,8 +889,13 @@ pub async fn cmd_edit_message(
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;

let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;
let builder = buzz_sdk::build_edit_with_editor(
channel_uuid,
target_eid,
content,
Some(&client.keys().public_key().to_hex()),
)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;

let event = client.sign_event(builder)?;

Expand Down
58 changes: 54 additions & 4 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,59 @@ async fn enqueue_event_created_audit(
// DB is genuinely overloaded and the relay should slow down rather than
// accumulate unbounded in-memory state. DB write failures in the worker are
// logged but not retried (same as the previous per-event tokio::spawn).
let mut detail = serde_json::json!({
"event_kind": kind_u32,
"channel_id": stored_event.channel_id,
});
if kind_u32 == buzz_core::kind::KIND_STREAM_MESSAGE_EDIT {
let target_id = stored_event.event.tags.iter().find_map(|tag| {
if tag.kind().to_string() == "e" {
tag.content().and_then(|value| {
let bytes = hex::decode(value).ok()?;
(bytes.len() == 32).then_some(hex::encode(bytes))
})
} else {
None
}
});
if let Some(target_id) = target_id {
detail["target_event_id"] = serde_json::Value::String(target_id.clone());
if let Some(edited_by) = stored_event.event.tags.iter().find_map(|tag| {
(tag.kind().to_string() == "edited_by")
.then(|| tag.content().map(str::to_string))
.flatten()
}) {
detail["edited_by"] = serde_json::Value::String(edited_by);
}
if let (Some(channel_id), Ok(Some(target_event))) = (
stored_event.channel_id,
state
.db
.get_event_by_id_for_event_write(
tenant.community(),
&hex::decode(&target_id).unwrap_or_default(),
)
.await,
) {
let author = super::ingest::effective_message_author(
&target_event.event,
&state.relay_keypair.public_key(),
);
let members = state.db.get_members(tenant.community(), channel_id).await;
let actor = stored_event.event.pubkey.to_bytes().to_vec();
let is_channel_admin = members.is_ok_and(|members| {
members.iter().any(|member| {
member.pubkey == actor && (member.role == "owner" || member.role == "admin")
})
});
if author != actor && is_channel_admin {
detail["type"] =
serde_json::Value::String("message_edited_by_admin".to_string());
detail["actor"] = serde_json::Value::String(actor_pubkey_hex.to_string());
}
}
}
}
let audit_entry = buzz_audit::NewAuditEntry {
community_id: tenant.community(),
action: buzz_audit::AuditAction::EventCreated,
Expand All @@ -589,10 +642,7 @@ async fn enqueue_event_created_audit(
// the pre-rewrite semantics, ported to the raw-bytes column.
actor_pubkey: hex::decode(actor_pubkey_hex).ok(),
object_id: Some(event_id_hex.to_owned()),
detail: serde_json::json!({
"event_kind": kind_u32,
"channel_id": stored_event.channel_id,
}),
detail,
};
if let Err(e) = audit_tx.send(audit_entry).await {
error!(event_id = %event_id_hex, "Audit channel closed — entry lost: {e}");
Expand Down
178 changes: 150 additions & 28 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,8 +1144,42 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ
event.pubkey.to_bytes().to_vec()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditAuthorization {
Author,
ChannelOwnerOrAdmin,
AgentOwner,
}

fn authorize_edit_actor(
actor: &[u8],
author: &[u8],
same_channel: bool,
channel_owner_or_admin: bool,
agent_owner: bool,
provenance_matches: bool,
) -> Result<EditAuthorization, String> {
if !same_channel {
return Err("target event belongs to a different channel".to_string());
}
if actor == author {
return Ok(EditAuthorization::Author);
}
if !provenance_matches {
return Err("non-author edits must carry an edited_by tag matching the editor".to_string());
}
if channel_owner_or_admin {
return Ok(EditAuthorization::ChannelOwnerOrAdmin);
}
if agent_owner {
return Ok(EditAuthorization::AgentOwner);
}
Err("must be event author or channel owner/admin".to_string())
}

/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author,
/// or the actor must be the owning human of the agent that authored the target message.
/// or the actor must be a channel owner/admin or the owning human of the agent
/// that authored the target message.
async fn validate_edit_ownership(
community_id: CommunityId,
event: &Event,
Expand Down Expand Up @@ -1180,48 +1214,86 @@ async fn validate_edit_ownership(

// Verify target belongs to the same channel as the edit event.
let edit_channel_id = extract_channel_id(event);
match (edit_channel_id, target_event.channel_id) {
let same_channel = match (edit_channel_id, target_event.channel_id) {
(Some(edit_ch), Some(target_ch)) if edit_ch != target_ch => {
return Err("target event belongs to a different channel".to_string());
}
(Some(_), None) => {
return Err("target event has no channel".to_string());
}
_ => {} // Same channel or no channel context — OK
}
_ => true, // Same channel or no channel context — OK
};

let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key());
let actor = event.pubkey.to_bytes().to_vec();
if author == actor {
// Author editing their own message: re-gate on membership/open visibility so that
// a removed private-channel member cannot mutate old messages after access is revoked.
if let Some(ch_id) = target_event.channel_id {
let is_member = state
.is_member_cached(community_id, ch_id, &actor)
.await
.map_err(|e| format!("db error checking membership: {e}"))?;
if !is_member {
let is_open = state
.db
.get_channel_for_event_write(community_id, ch_id)
.await
.map(|ch| ch.visibility == "open")
.unwrap_or(false);
if !is_open {
return Err("restricted: not a channel member".to_string());
}
}
let edited_by = event.tags.iter().find_map(|tag| {
if tag.kind().to_string() == "edited_by" {
tag.content().map(str::to_string)
} else {
None
}
});
let actor_hex = hex::encode(&actor);
let provenance_matches = edited_by
.as_deref()
.map(|value| value.eq_ignore_ascii_case(&actor_hex))
.unwrap_or(false);

let channel_owner_or_admin = if author == actor {
false
} else if let Some(ch_id) = target_event.channel_id {
let members = state
.db
.get_members(community_id, ch_id)
.await
.map_err(|e| format!("db error checking channel role: {e}"))?;
members.iter().any(|member| {
member.pubkey == actor && (member.role == "owner" || member.role == "admin")
})
} else {
// Allow the owning human to edit messages authored by their agent.
let is_owner = state
false
};

let agent_owner = if author == actor || channel_owner_or_admin {
false
} else {
state
.db
.is_agent_owner(community_id, &author, &actor)
.await
.map_err(|e| format!("db error checking agent ownership: {e}"))?;
if !is_owner {
return Err("must be event author to edit".to_string());
.map_err(|e| format!("db error checking agent ownership: {e}"))?
};

match authorize_edit_actor(
&actor,
&author,
same_channel,
channel_owner_or_admin,
agent_owner,
provenance_matches,
)? {
EditAuthorization::Author => {
// Author editing their own message: re-gate on membership/open visibility so that
// a removed private-channel member cannot mutate old messages after access is revoked.
if let Some(ch_id) = target_event.channel_id {
let is_member = state
.is_member_cached(community_id, ch_id, &actor)
.await
.map_err(|e| format!("db error checking membership: {e}"))?;
if !is_member {
let is_open = state
.db
.get_channel_for_event_write(community_id, ch_id)
.await
.map(|ch| ch.visibility == "open")
.unwrap_or(false);
if !is_open {
return Err("restricted: not a channel member".to_string());
}
}
}
}
EditAuthorization::ChannelOwnerOrAdmin | EditAuthorization::AgentOwner => {}
}
Ok(())
}
Expand Down Expand Up @@ -3309,6 +3381,56 @@ mod postgres_tests {
};
use nostr::{EventBuilder, Kind};

#[test]
fn non_author_non_admin_edit_is_rejected() {
let actor = [1_u8; 32];
let author = [2_u8; 32];
let error = authorize_edit_actor(&actor, &author, true, false, false, true)
.expect_err("ordinary members must not edit another author's message");
assert_eq!(error, "must be event author or channel owner/admin");
}

#[test]
fn same_channel_admin_edit_is_authorized_with_provenance() {
let actor = [1_u8; 32];
let author = [2_u8; 32];
assert_eq!(
authorize_edit_actor(&actor, &author, true, true, false, true),
Ok(EditAuthorization::ChannelOwnerOrAdmin)
);
}

#[test]
fn admin_of_another_channel_is_rejected_before_role_authorization() {
let actor = [1_u8; 32];
let author = [2_u8; 32];
let error = authorize_edit_actor(&actor, &author, false, true, false, true)
.expect_err("channel roles must not cross channel boundaries");
assert_eq!(error, "target event belongs to a different channel");
}

#[test]
fn channel_owner_edit_is_authorized_with_provenance() {
let actor = [1_u8; 32];
let author = [2_u8; 32];
assert_eq!(
authorize_edit_actor(&actor, &author, true, true, false, true),
Ok(EditAuthorization::ChannelOwnerOrAdmin)
);
}

#[test]
fn non_author_edit_requires_matching_provenance() {
let actor = [1_u8; 32];
let author = [2_u8; 32];
let error = authorize_edit_actor(&actor, &author, true, true, false, false)
.expect_err("admin edits must identify their signed editor");
assert_eq!(
error,
"non-author edits must carry an edited_by tag matching the editor"
);
}

#[test]
fn missing_huddle_backing_channel_is_a_client_rejection() {
let channel_id = Uuid::new_v4();
Expand Down
Loading
Loading