diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807..0fcbca473a 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -38,30 +38,42 @@ pub async fn handle_command( state: &Arc, event: Event, auth: IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, ) -> Result { // Ensure the authenticated user exists in the users table (foreign key requirement). // The old REST handlers did this via extract_auth_context; command executor must do it explicitly. let pubkey_bytes = auth.pubkey().to_bytes().to_vec(); - match state - .db - .ensure_user(tenant.community(), &pubkey_bytes) - .await - { - Ok(true) => { - metrics::counter!( - "buzz_users_created_total", - "community" => tenant.host().to_owned() - ) - .increment(1); - } - Ok(false) => {} - Err(e) => { - tracing::warn!("command_executor: ensure_user failed: {e}"); + if !protected.is_enforcing() { + match state + .db + .ensure_user(tenant.community(), &pubkey_bytes) + .await + { + Ok(true) => { + metrics::counter!( + "buzz_users_created_total", + "community" => crate::metrics::community_label(tenant.community()) + ) + .increment(1); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("command_executor: ensure_user failed: {e}"); + } } } let kind = event.kind.as_u16() as u32; match kind { + KIND_DM_OPEN if protected.is_enforcing() => { + handle_dm_open_enforced(tenant, state, &event, &auth, protected).await + } + KIND_DM_ADD_MEMBER if protected.is_enforcing() => { + handle_dm_add_member_enforced(tenant, state, &event, &auth, protected).await + } + KIND_DM_HIDE if protected.is_enforcing() => { + handle_dm_hide_enforced(tenant, state, &event, &auth, protected).await + } KIND_DM_OPEN => handle_dm_open(tenant, state, &event, &auth).await, KIND_DM_ADD_MEMBER => handle_dm_add_member(tenant, state, &event, &auth).await, KIND_DM_HIDE => handle_dm_hide(tenant, state, &event, &auth).await, @@ -307,6 +319,237 @@ fn compute_definition_hash(json_str: &str) -> Vec { Sha256::digest(json_str.as_bytes()).to_vec() } +async fn begin_protected_command( + state: &AppState, + tenant: &TenantContext, + event: &Event, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.command.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-command-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "event.command.v1", request.finalize().into()) + .map_err(|_| IngestError::AuthFailed("restricted: protected authorization denied".into()))? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}"))) +} + +fn replayed_command_result(event: &Event, payload: Vec) -> Result { + let message = String::from_utf8(payload) + .map_err(|_| IngestError::Internal("error: protected command receipt is invalid".into()))?; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) +} + +async fn persist_command_event_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + event: &Event, +) -> Result<(), IngestError> { + buzz_db::event::insert_event_with_thread_metadata_tx( + transaction, + tenant.community(), + event, + extract_channel_id(event), + None, + ) + .await + .map(|_| ()) + .map_err(|error| IngestError::Internal(format!("error: persist command: {error}"))) +} + +async fn handle_dm_open_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let tags = extract_p_tags(event); + if tags.is_empty() || tags.len() > 8 { + return Err(IngestError::Rejected( + "invalid: DM requires 1-8 other participants".into(), + )); + } + let mut participants = vec![actor.clone()]; + for tag in tags { + let pubkey = decode_pubkey(&tag)?; + if !participants.contains(&pubkey) { + participants.push(pubkey); + } + } + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + for participant in &participants { + buzz_db::user::ensure_user_tx( + operation.transaction(), + tenant.community(), + participant, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: ensure DM participant: {error}")) + })?; + } + persist_command_event_tx(operation.transaction(), tenant, event).await?; + let refs = participants.iter().map(Vec::as_slice).collect::>(); + let (channel, created) = + buzz_db::dm::open_dm_tx(operation.transaction(), tenant.community(), &refs, &actor) + .await + .map_err(|error| IngestError::Internal(format!("error: open DM: {error}")))?; + let message = format!( + "response:{}", + serde_json::json!({ + "channel_id": channel.id.to_string(), + "created": created, + }) + ); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + for participant in &participants { + state.invalidate_membership(tenant, channel.id, participant); + } + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + +async fn handle_dm_add_member_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let channel_id = extract_h_tag(event) + .and_then(|value| Uuid::parse_str(&value).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: missing or malformed h tag".into()))?; + let additions = extract_p_tags(event) + .into_iter() + .map(|value| decode_pubkey(&value)) + .collect::, _>>()?; + if additions.is_empty() { + return Err(IngestError::Rejected( + "invalid: at least one participant is required".into(), + )); + } + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + for participant in additions.iter().chain(std::iter::once(&actor)) { + buzz_db::user::ensure_user_tx( + operation.transaction(), + tenant.community(), + participant, + ) + .await + .map_err(|error| { + IngestError::Internal(format!("error: ensure DM participant: {error}")) + })?; + } + persist_command_event_tx(operation.transaction(), tenant, event).await?; + let (channel, _created, participants) = buzz_db::dm::expand_dm_tx( + operation.transaction(), + tenant.community(), + channel_id, + &additions, + &actor, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let message = format!( + "response:{}", + serde_json::json!({"channel_id": channel.id.to_string()}) + ); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + for participant in participants { + state.invalidate_membership(tenant, channel.id, &participant); + } + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + +async fn handle_dm_hide_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result { + let actor = auth.pubkey().to_bytes().to_vec(); + let channel_id = extract_h_tag(event) + .and_then(|value| Uuid::parse_str(&value).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: missing or malformed h tag".into()))?; + match begin_protected_command(state, tenant, event, protected).await? { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + replayed_command_result(event, payload) + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + persist_command_event_tx(operation.transaction(), tenant, event).await?; + buzz_db::dm::hide_dm_tx( + operation.transaction(), + tenant.community(), + channel_id, + &actor, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + let message = "{}".to_string(); + operation + .commit(message.as_bytes()) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message, + }) + } + } +} + async fn handle_dm_open( tenant: &TenantContext, state: &Arc, @@ -374,7 +617,7 @@ async fn handle_dm_open( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); @@ -535,7 +778,7 @@ async fn handle_dm_add_member( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 3eeab5e807..7b4cce662e 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -50,6 +50,40 @@ pub async fn handle_count( } }; + let protected_result = match state.conn_manager.authority_for_conn(conn.conn_id) { + Some(proof) => { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + proof, + state + .conn_manager + .federated_assertion_for_conn(conn.conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_count", + conn.conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = Arc::new(match protected_result { + Ok(authority) => authority, + Err(error) => { + warn!(error = %error, "protected COUNT authorization denied"); + conn.send_terminal(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }); + // P-gated kinds (gift wraps, member notifications, observer frames) require // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. let authed_pubkey_hex = hex::encode(&pubkey_bytes); @@ -83,7 +117,10 @@ pub async fn handle_count( Ok(ids) => ids, Err(e) => { warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } }; @@ -98,6 +135,7 @@ pub async fn handle_count( // For each filter, count matching events with channel access enforcement. let mut total: u64 = 0; + let mut release_channels = std::collections::BTreeSet::new(); for filter in &filters { // Determine if this filter can match author-only kinds — if so, the // fast-path count_events() cannot be used because it doesn't do @@ -134,7 +172,10 @@ pub async fn handle_count( Ok(member) => Some(member), Err(e) => { warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -149,6 +190,7 @@ pub async fn handle_count( ) { continue; // Skip filters targeting inaccessible channels. } + release_channels.insert(ch_id); // Channel is accessible — count with pushability check. let mut query = super::req::build_event_query_from_filter( filter, @@ -176,7 +218,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -192,10 +237,13 @@ pub async fn handle_count( Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: count filter requires narrower constraints", - )); + conn.send_protected( + RelayMessage::closed( + &sub_id, + "restricted: count filter requires narrower constraints", + ), + Arc::clone(&protected), + ); return; } for se in stored_events { @@ -210,7 +258,10 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -222,6 +273,7 @@ pub async fn handle_count( // If the filter has generic tags beyond what SQL can push down // (#h, #p single, #d single, #e), we must fall back to // query + post-filter to avoid overcounting. + release_channels.extend(accessible_channels.iter().copied()); let mut query = super::req::build_event_query_from_filter( filter, &pubkey_bytes, @@ -250,7 +302,10 @@ pub async fn handle_count( match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } @@ -265,10 +320,13 @@ pub async fn handle_count( Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); - conn.send(RelayMessage::closed( - &sub_id, - "restricted: count filter requires narrower constraints", - )); + conn.send_protected( + RelayMessage::closed( + &sub_id, + "restricted: count filter requires narrower constraints", + ), + Arc::clone(&protected), + ); return; } for se in stored_events { @@ -283,12 +341,24 @@ pub async fn handle_count( } } Err(e) => { - conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + conn.send_protected( + RelayMessage::closed(&sub_id, &format!("error: {e}")), + Arc::clone(&protected), + ); return; } } } } } - conn.send(RelayMessage::count(&sub_id, total)); + let release = crate::connection::queued_channel_set_read_authority( + state.db.clone(), + conn.tenant.community(), + release_channels.into_iter().collect(), + pubkey_bytes, + Some(protected), + ); + if !conn.send_guarded(RelayMessage::count(&sub_id, total), release) { + conn.cancel.cancel(); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 288129fd62..873f0b21ad 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -31,6 +31,35 @@ fn reject(reason: &'static str) { reject_with_transport("ws", reason); } +fn seal_ephemeral_authority( + state: &AppState, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, + event: &Event, +) -> Result, crate::authorization_runtime::ephemeral::EphemeralAuthorityError> { + authority + .is_enforcing() + .then(|| crate::authorization_runtime::ephemeral::seal(state, authority, event)) + .transpose() +} + +async fn publish_ephemeral_event( + state: &AppState, + tenant: &TenantContext, + topic: EventTopic, + event: &Event, + authority: Option<&str>, +) -> Result { + match authority { + Some(authority) => { + state + .pubsub + .publish_event_with_authority(tenant, topic, event, authority) + .await + } + None => state.pubsub.publish_event(tenant, topic, event).await, + } +} + /// Bound the `kind` label to prevent cardinality explosion from arbitrary Nostr kinds. pub(crate) fn bounded_kind_label(kind: u32) -> String { match kind { @@ -73,23 +102,70 @@ where frames } +/// A live fan-out target that retains exact authority until socket drain. +pub struct ProtectedFanoutRecipient { + conn_id: crate::subscription::ConnId, + sub_id: crate::subscription::SubId, + authority: Option>, +} + +impl std::fmt::Debug for ProtectedFanoutRecipient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProtectedFanoutRecipient") + .field("conn_id", &self.conn_id) + .field("sub_id", &self.sub_id) + .field("guarded", &self.authority.is_some()) + .finish() + } +} + +impl PartialEq<(crate::subscription::ConnId, crate::subscription::SubId)> + for ProtectedFanoutRecipient +{ + fn eq(&self, other: &(crate::subscription::ConnId, crate::subscription::SubId)) -> bool { + self.conn_id == other.0 && self.sub_id == other.1 + } +} + fn send_fanout_frames<'a, I>( state: &AppState, recipients: I, frames: &HashMap<&'a str, Arc>, + sender_authority: Option<&Arc>, ) -> u32 where - I: IntoIterator, + I: IntoIterator, { let mut drop_count = 0u32; - for (conn_id, sub_id) in recipients { + for recipient in recipients { let frame = frames - .get(sub_id) + .get(recipient.sub_id.as_str()) .expect("fan-out frame cache covers every recipient subscription id"); - if !state - .conn_manager - .send_to_text_bytes(conn_id, Arc::clone(frame)) - { + let sent = match (&recipient.authority, sender_authority) { + (Some(recipient_authority), Some(sender_authority)) => { + state.conn_manager.send_to_text_bytes_guarded_pair( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(sender_authority), + Arc::clone(recipient_authority), + ) + } + (Some(authority), None) => state.conn_manager.send_to_text_bytes_guarded( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(authority), + ), + (None, Some(sender_authority)) => state.conn_manager.send_to_text_bytes_guarded( + recipient.conn_id, + Arc::clone(frame), + Arc::clone(sender_authority), + ), + (None, None) => state + .conn_manager + .send_to_text_bytes(recipient.conn_id, Arc::clone(frame)), + }; + if !sent { drop_count += 1; } } @@ -118,7 +194,7 @@ pub async fn filter_fanout_by_access( stored_event: &StoredEvent, matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, threaded: Option<&crate::state::ThreadedChannelVisibility>, -) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { +) -> Vec { // First enforce the receiver-side tenant label. Subscription indexes are // community-scoped, but stale/injected matches and future fan-out helpers // must still fail closed at the send chokepoint: a connection bound to @@ -175,7 +251,7 @@ pub async fn filter_fanout_by_access( }; let Some(channel_id) = stored_event.channel_id else { - return matches; + return filter_fanout_by_protected_authorization(state, community_id, None, matches).await; }; // Fence 3 (§4.8 phase-2): the threaded value is used only when it was // resolved under exactly this (community_id, channel_id); anything else @@ -192,7 +268,15 @@ pub async fn filter_fanout_by_access( } }; match visibility { - Ok(v) if v != "private" => return matches, + Ok(v) if v != "private" => { + return filter_fanout_by_protected_authorization( + state, + community_id, + Some(channel_id), + matches, + ) + .await; + } Ok(_) => {} Err(e) => { // Fail closed: if we cannot determine visibility, do not leak a @@ -218,6 +302,89 @@ pub async fn filter_fanout_by_access( } } } + filter_fanout_by_protected_authorization(state, community_id, Some(channel_id), allowed).await +} + +async fn filter_fanout_by_protected_authorization( + state: &AppState, + community_id: CommunityId, + channel_id: Option, + matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, +) -> Vec { + if state.protected_transport().is_none() { + return matches + .into_iter() + .map(|(conn_id, sub_id)| { + let authority = match channel_id { + Some(channel_id) => state.conn_manager.pubkey_for_conn(conn_id).map(|actor| { + crate::connection::queued_channel_read_authority( + state.db.clone(), + community_id, + channel_id, + actor.to_vec(), + None, + ) + }), + None => None, + }; + ProtectedFanoutRecipient { + conn_id, + sub_id, + authority, + } + }) + .collect(); + } + let mut allowed = Vec::with_capacity(matches.len()); + for (conn_id, sub_id) in matches { + let Some(proof) = state.conn_manager.authority_for_conn(conn_id) else { + continue; + }; + if proof.authorization_domain() != community_id { + state.conn_manager.cancel_connection(conn_id); + continue; + } + let Some(cancellation) = state.conn_manager.cancellation_for_conn(conn_id) else { + continue; + }; + match crate::authorization_runtime::transport::authorize_session_if_configured( + state, + proof, + state.conn_manager.federated_assertion_for_conn(conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_fanout", + conn_id, + cancellation, + ) + .await + { + Ok(authority) if authority.revalidate().is_ok() => { + let authority = Arc::new(authority); + let release = match channel_id { + Some(channel_id) => { + let Some(actor) = state.conn_manager.pubkey_for_conn(conn_id) else { + continue; + }; + crate::connection::queued_channel_read_authority( + state.db.clone(), + community_id, + channel_id, + actor.to_vec(), + Some(authority), + ) + } + None => crate::connection::queued_local_authority(authority), + }; + allowed.push(ProtectedFanoutRecipient { + conn_id, + sub_id, + authority: Some(release), + }); + } + Ok(_) | Err(_) => state.conn_manager.cancel_connection(conn_id), + } + } allowed } @@ -243,6 +410,17 @@ pub(crate) async fn fan_out_event_to_local_subscribers( community_id: CommunityId, stored: &StoredEvent, ) { + fan_out_event_to_local_subscribers_with_authority(state, community_id, stored, None).await; +} + +async fn fan_out_event_to_local_subscribers_with_authority( + state: &AppState, + community_id: CommunityId, + stored: &StoredEvent, + sender_authority: Option<&Arc>, +) { + let sender_authority = sender_authority + .map(|authority| crate::connection::queued_local_authority(Arc::clone(authority))); let matches = state.sub_registry.fan_out_scoped(community_id, stored); let matches = filter_fanout_by_access(state, community_id, stored, matches, None).await; metrics::histogram!("buzz_fanout_recipients").record(matches.len() as f64); @@ -258,16 +436,10 @@ pub(crate) async fn fan_out_event_to_local_subscribers( } }; let frames = fanout_frame_cache( - matches.iter().map(|(_, sub_id)| sub_id.as_str()), + matches.iter().map(|recipient| recipient.sub_id.as_str()), &event_json, ); - let drop_count = send_fanout_frames( - state, - matches - .iter() - .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())), - &frames, - ); + let drop_count = send_fanout_frames(state, matches.iter(), &frames, sender_authority.as_ref()); if drop_count > 0 { tracing::warn!( event_id = %stored.event.id.to_hex(), @@ -280,16 +452,50 @@ pub(crate) async fn fan_out_event_to_local_subscribers( /// Fan out one event received from Redis pub/sub to this relay's local subscribers. #[tracing::instrument(skip_all)] pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pubsub::ChannelEvent) { + let buzz_pubsub::ChannelEvent { + community_id, + topic, + event, + authority, + } = channel_event; // The Redis topic carries the tenant-local routing scope explicitly: // `Channel(id)` for a per-channel event, `Global` for a channel-less one. // Convert back to the `Option` channel id `fan_out()` indexes on — // `Global` selects the global subscriber index. - let channel_id = match channel_event.topic { + let channel_id = match topic { buzz_pubsub::EventTopic::Channel(id) => Some(id), buzz_pubsub::EventTopic::Global => None, }; - let community_id = channel_event.community_id; - let stored = StoredEvent::new(channel_event.event, channel_id); + let protected_ephemeral = + is_ephemeral(event_kind_u32(&event)) || event_kind_u32(&event) == KIND_AGENT_OBSERVER_FRAME; + let sender_authority = match authority { + Some(authority) if protected_ephemeral => { + match crate::authorization_runtime::ephemeral::verify( + state, + community_id, + &event, + &authority, + ) + .await + { + Ok(authority) => Some(authority), + Err(error) => { + warn!(%error, "multi-node ephemeral sender authority denied"); + return; + } + } + } + Some(_) => { + warn!("multi-node persistent event carried unexpected sender authority"); + return; + } + None if protected_ephemeral && state.is_protected_enforcing(community_id) => { + warn!("multi-node Enforce ephemeral event omitted sender authority"); + return; + } + None => None, + }; + let stored = StoredEvent::new(event, channel_id); // Skip events that were already fanned out in-process (local echo). The // dedup key is `(community_id, event_id)` — a same-id event arriving for a @@ -318,16 +524,10 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } }; let frames = fanout_frame_cache( - matches.iter().map(|(_, sub_id)| sub_id.as_str()), + matches.iter().map(|recipient| recipient.sub_id.as_str()), &event_json, ); - let drop_count = send_fanout_frames( - state, - matches - .iter() - .map(|(conn_id, sub_id)| (*conn_id, sub_id.as_str())), - &frames, - ); + let drop_count = send_fanout_frames(state, matches.iter(), &frames, sender_authority.as_ref()); if drop_count > 0 { tracing::warn!( event_id = %stored.event.id.to_hex(), @@ -355,15 +555,17 @@ pub(crate) async fn dispatch_persistent_event( threaded_visibility: Option, ) -> usize { let event_id_hex = stored_event.event.id.to_hex(); - enqueue_event_created_audit( - tenant, - state, - stored_event, - kind_u32, - actor_pubkey_hex, - &event_id_hex, - ) - .await; + if legacy_audit_delivery_allowed(state, tenant.community()) { + enqueue_event_created_audit( + tenant, + state, + stored_event, + kind_u32, + actor_pubkey_hex, + &event_id_hex, + ) + .await; + } let tenant = tenant.clone(); let state = Arc::clone(state); @@ -476,21 +678,20 @@ async fn dispatch_persistent_event_inner( // frames only after applying it to the already access-filtered recipient set. let recipients: Vec<_> = matches .iter() - .filter_map(|(target_conn_id, sub_id)| { - if let Some(ref owner_hex) = private_event_owner { - let is_owner = state + .filter(|recipient| { + private_event_owner.as_ref().is_none_or(|owner_hex| { + state .conn_manager - .pubkey_for(*target_conn_id) - .is_some_and(|pk| hex::encode(pk) == *owner_hex); - if !is_owner { - return None; - } - } - Some((*target_conn_id, sub_id.as_str())) + .pubkey_for(recipient.conn_id) + .is_some_and(|pk| hex::encode(pk) == *owner_hex) + }) }) .collect(); - let frames = fanout_frame_cache(recipients.iter().map(|(_, sub_id)| *sub_id), &event_json); - let drop_count = send_fanout_frames(state, recipients, &frames); + let frames = fanout_frame_cache( + recipients.iter().map(|recipient| recipient.sub_id.as_str()), + &event_json, + ); + let drop_count = send_fanout_frames(state, recipients, &frames, None); if drop_count > 0 { tracing::warn!( event_id = %event_id_hex, @@ -505,7 +706,7 @@ async fn dispatch_persistent_event_inner( // out-of-band index to feed. The old Typesense `index_event` worker and its // `search_index_tx` mpsc are gone with the Typesense backend. - if enqueue_audit { + if enqueue_audit && legacy_audit_delivery_allowed(state, tenant.community()) { enqueue_event_created_audit( tenant, state, @@ -525,7 +726,15 @@ async fn dispatch_persistent_event_inner( .iter() .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")); - if !buzz_core::kind::is_workflow_execution_kind(kind_u32) + let workflow_effect_allowed = crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(tenant.community())), + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .is_ok(); + if workflow_effect_allowed + && !buzz_core::kind::is_workflow_execution_kind(kind_u32) && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP @@ -533,24 +742,24 @@ async fn dispatch_persistent_event_inner( let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); let trigger_kind = kind_u32.to_string(); - let workflow_community_host = tenant.host().to_owned(); // The event was stored under `tenant.community()`; `StoredEvent` does // not carry the community, so pass it explicitly. The same channel UUID // can exist in another community — scoping the workflow lookup to this // community keeps a colliding channel id in B from triggering A's // workflows. let workflow_community = tenant.community(); + let workflow_community_label = crate::metrics::community_label(workflow_community); tokio::spawn(async move { if let Err(e) = workflow_engine .on_event(workflow_community, &workflow_event) .await { - tracing::error!(event_id = ?workflow_event.event.id, "Workflow trigger failed: {e}"); + tracing::error!("Workflow trigger failed: {e}"); } else { metrics::counter!( "buzz_workflow_runs_total", "trigger" => trigger_kind, - "community" => workflow_community_host + "community" => workflow_community_label ) .increment(1); } @@ -560,6 +769,16 @@ async fn dispatch_persistent_event_inner( matches.len() } +fn legacy_audit_delivery_allowed(state: &AppState, community_id: CommunityId) -> bool { + crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)), + crate::protected_surface::EffectSurfaceId::LegacyAuditDelivery, + ) + .is_ok() +} + async fn enqueue_event_created_audit( tenant: &TenantContext, state: &Arc, @@ -622,22 +841,20 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc kind_str).increment(1); - // Per-community volume counter: community-only, no kind tag. - // Use this for per-community throughput graphs; the fleet counter above - // for per-kind breakdowns. metrics::counter!( "buzz_community_events_received_total", - "community" => conn.tenant.host().to_owned() + "community" => crate::metrics::community_label(conn.tenant.community()) ) .increment(1); - let (conn_id, pubkey_bytes, auth_pubkey, scopes, channel_ids) = { + let (conn_id, pubkey_bytes, auth_pubkey, owner_pubkey, scopes, channel_ids) = { let auth = conn.auth_state.read().await; match &*auth { AuthState::Authenticated(ctx) => ( conn.conn_id, ctx.pubkey.to_bytes().to_vec(), ctx.pubkey, + ctx.agent_owner_pubkey, ctx.scopes.clone(), ctx.channel_ids.clone(), ), @@ -677,6 +894,39 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + Arc::clone(proof), + state.conn_manager.federated_assertion_for_conn(conn_id), + crate::protected_surface::event_ingest_capability(kind_u32), + uuid::Uuid::new_v4(), + "ws_event", + conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = match protected_result { + Ok(authority) => Arc::new(authority), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "protected EVENT authorization denied"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }; if kind_u32 == KIND_AGENT_OBSERVER_FRAME { if !scopes.is_empty() && !scopes.contains(&buzz_auth::Scope::MessagesWrite) { reject("scope"); @@ -687,7 +937,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, state: Arc, state: Arc, auth_pubkey: nostr::PublicKey, conn: Arc, state: Arc, + authority: Arc, ) { + let conn_id = conn.conn_id; let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -790,6 +1060,14 @@ async fn handle_ephemeral_event( return; } } + let redis_authority = match seal_ephemeral_authority(&state, &authority, &event) { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, %error, "ephemeral sender authority could not be sealed"); + conn.cancel.cancel(); + return; + } + }; // Special handling for presence events (kind:20001). if event_kind_u32(&event) == KIND_PRESENCE_UPDATE { @@ -810,16 +1088,49 @@ async fn handle_ephemeral_event( raw }; - if status == "offline" { - let _ = state + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + let stored_status = match redis_authority.as_ref() { + Some(token) => match crate::authorization_runtime::ephemeral::encode_presence( + status.clone(), + event.id.to_bytes(), + token.clone(), + ) { + Ok(value) => value, + Err(_) => { + conn.cancel.cancel(); + return; + } + }, + None => status.clone(), + }; + let presence_result = if status == "offline" { + state .pubsub .clear_presence(&conn.tenant, &auth_pubkey) - .await; + .await } else { + state + .pubsub + .set_presence(&conn.tenant, &auth_pubkey, &stored_status) + .await + }; + if authority.revalidate().is_err() { + // Cleanup is opportunistic. Protected values retain their sealed + // authority and are revalidated before every read or emission, so + // a failed DEL cannot make stale presence visible. let _ = state .pubsub - .set_presence(&conn.tenant, &auth_pubkey, &status) + .clear_presence(&conn.tenant, &auth_pubkey) .await; + conn.cancel.cancel(); + return; + } + if presence_result.is_err() && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Presence is a channel-less ephemeral event. After updating Redis @@ -841,20 +1152,78 @@ async fn handle_ephemeral_event( conn.send(RelayMessage::ok(event_id_hex, false, &msg)); return; } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + + // In Enforce, retain database locks on the channel and the actor's + // active membership through the authoritative Redis publication. A + // concurrent removal or open-to-private transition therefore orders + // entirely before this publication (which then denies) or after it. + // Off/Shadow/VerifyOnly keep the legacy preflight behavior. + let channel_authority_guard = if authority.is_enforcing() { + let mut transaction = match state.db.begin_transaction().await { + Ok(transaction) => transaction, + Err(error) => { + warn!(%error, %ch_id, "ephemeral channel authority transaction failed"); + conn.cancel.cancel(); + return; + } + }; + if let Err(error) = buzz_db::channel::require_channel_write_authority_tx( + &mut transaction, + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: channel authority changed before publication", + )); + warn!(%error, %ch_id, "ephemeral channel authority denied"); + return; + } + Some(transaction) + } else { + None + }; // Mark as local before Redis publish to prevent double-delivery when // the event comes back through the Redis subscriber loop. state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Channel(ch_id), &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Channel(ch_id), + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + drop(channel_authority_guard); + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Direct fan-out to local WS subscribers, through the guarded send path @@ -862,7 +1231,21 @@ async fn handle_ephemeral_event( // receive this private-channel ephemeral event. // Pass the channel_id so fan_out() uses the channel-kind index. let stored_event = StoredEvent::new(event.clone(), Some(ch_id)); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } } else { // Channel-less ephemeral events (e.g., NIP-AB pairing kind:24134). // @@ -872,17 +1255,39 @@ async fn handle_ephemeral_event( // The nil UUID is ONLY a Redis routing key — it never reaches the DB. // On the receiving end (main.rs subscriber loop), `is_nil()` is checked // and converted back to `None` so `fan_out()` uses the global index. + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Global, &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Global, + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral global publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } // Direct fan-out to local WS subscribers through the guarded send path. @@ -890,9 +1295,27 @@ async fn handle_ephemeral_event( // filter_fanout_by_access no-ops for channel-less events except the // author-only-kind gate. let stored_event = StoredEvent::new(event.clone(), None); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } conn.send(RelayMessage::ok(event_id_hex, true, "")); } @@ -946,6 +1369,7 @@ async fn handle_agent_observer_event( event_id_hex: &str, conn: Arc, state: Arc, + authority: Arc, ) { let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -968,6 +1392,14 @@ async fn handle_agent_observer_event( return; } } + let redis_authority = match seal_ephemeral_authority(&state, &authority, &event) { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, %error, "observer sender authority could not be sealed"); + conn.cancel.cancel(); + return; + } + }; // Freshness check: reject observer frames with stale/future timestamps let now = chrono::Utc::now().timestamp(); @@ -1050,6 +1482,10 @@ async fn handle_agent_observer_event( )); return; } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } // Rate limit telemetry frames only (100/sec per agent). // Control frames (owner → agent) bypass the limiter — they are rare and must not @@ -1066,27 +1502,60 @@ async fn handle_agent_observer_event( } } + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } state.mark_local_event(conn.tenant.community(), &event.id); - if let Err(e) = state - .pubsub - .publish_event(&conn.tenant, EventTopic::Global, &event) - .await + let publish_failed = if let Err(e) = publish_ephemeral_event( + &state, + &conn.tenant, + EventTopic::Global, + &event, + redis_authority.as_deref(), + ) + .await { state .local_event_ids .invalidate(&(conn.tenant.community(), event.id.to_bytes())); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Agent observer publish failed: {e}"); + true + } else { + false + }; + if authority.revalidate().is_err() { + state + .local_event_ids + .invalidate(&(conn.tenant.community(), event.id.to_bytes())); + conn.cancel.cancel(); + return; + } + if publish_failed && authority.is_enforcing() { + conn.cancel.cancel(); + return; } let stored_event = StoredEvent::new(event.clone(), None); debug!( - event_id = %event_id_hex, - agent = %route.agent.to_hex(), - owner = %route.owner.to_hex(), direction = ?route.direction, "Agent observer fan-out" ); - fan_out_event_to_local_subscribers(&state, conn.tenant.community(), &stored_event).await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + fan_out_event_to_local_subscribers_with_authority( + &state, + conn.tenant.community(), + &stored_event, + Some(&authority), + ) + .await; + if authority.revalidate().is_err() { + conn.cancel.cancel(); + return; + } conn.send(RelayMessage::ok(event_id_hex, true, "")); } @@ -1386,7 +1855,7 @@ mod tests { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), - corporate_identity_jwt: None, + corporate_identity_assertion: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), @@ -1410,11 +1879,12 @@ mod tests { &event.id.to_hex(), conn, state, + Arc::new(crate::authorization_runtime::transport::ProtectedAuthorization::Legacy), ) .await; let axum::extract::ws::Message::Text(text) = - send_rx.try_recv().expect("observer rejection sent") + send_rx.try_recv().expect("observer rejection sent").message else { panic!("expected text relay message"); }; @@ -1452,7 +1922,7 @@ mod tests { sub_id: &str, filter: Filter, pubkey: Option>, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { let conn_id = Uuid::new_v4(); let (tx, rx) = mpsc::channel(10); let (ctrl_tx, _ctrl_rx) = mpsc::channel(10); @@ -1478,7 +1948,7 @@ mod tests { fn register_presence_sub( state: &AppState, sub_id: &str, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { register_global_sub( state, sub_id, @@ -1491,7 +1961,7 @@ mod tests { state: &AppState, sub_id: &str, target: &Keys, - ) -> (Uuid, mpsc::Receiver) { + ) -> (Uuid, mpsc::Receiver) { register_global_sub( state, sub_id, @@ -1540,11 +2010,13 @@ mod tests { community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), topic: EventTopic::Global, event, + authority: None, }, ) .await; - let delivered = event_from_ws_message(rx.try_recv().expect("presence delivered")); + let delivered = + event_from_ws_message(rx.try_recv().expect("presence delivered").message); assert_eq!(delivered.id, event_id); assert!(rx.try_recv().is_err(), "presence is delivered once"); } @@ -1563,6 +2035,7 @@ mod tests { community_id: community, topic: EventTopic::Global, event, + authority: None, }, ) .await; @@ -1605,13 +2078,15 @@ mod tests { community_id: community_b, topic: EventTopic::Global, event, + authority: None, }, ) .await; let delivered = event_from_ws_message( rx.try_recv() - .expect("B's same-id event must be delivered — A's local mark is B-irrelevant"), + .expect("B's same-id event must be delivered — A's local mark is B-irrelevant") + .message, ); assert_eq!(delivered.id, event_id); } @@ -1634,6 +2109,7 @@ mod tests { community_id: buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), topic: EventTopic::Global, event, + authority: None, }, ) .await; @@ -1641,7 +2117,8 @@ mod tests { let delivered = event_from_ws_message( target_rx .try_recv() - .expect("target receives membership notification"), + .expect("target receives membership notification") + .message, ); assert_eq!(delivered.id, event_id); assert!( @@ -1729,7 +2206,7 @@ mod tests { .await .expect("presence reached second relay") .expect("receiver connection still open"); - let delivered = event_from_ws_message(delivered); + let delivered = event_from_ws_message(delivered.message); assert_eq!(delivered.id, event_id); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), receiver_rx.recv()) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728..a8fa49ddd5 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -24,21 +24,23 @@ use buzz_core::kind::{ KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_CREATE_INVITE, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -65,6 +67,12 @@ pub enum IngestAuth { Nip42 { /// The authenticated Nostr public key. pubkey: nostr::PublicKey, + /// Verified delegated owner, when present. + owner_pubkey: Option, + /// Sealed NIP-42 proof retained from connection authentication. + verified_proof: Option>, + /// Current direct federated evidence retained from authentication. + verified_assertion: Option>, /// Permission scopes granted to this connection. scopes: Vec, /// Token-level channel restriction, if the WebSocket auth used an API token. @@ -76,6 +84,12 @@ pub enum IngestAuth { Http { /// The authenticated Nostr public key. pubkey: nostr::PublicKey, + /// Verified delegated owner, when present. + owner_pubkey: Option, + /// Sealed NIP-98 proof retained from this exact HTTP request. + verified_proof: Option>, + /// Current direct federated evidence retained from this request. + verified_assertion: Option>, /// Permission scopes granted to this request. scopes: Vec, /// How the HTTP request was authenticated. @@ -91,6 +105,34 @@ impl IngestAuth { } } + /// Verified delegated owner, when present. + pub fn owner_pubkey(&self) -> Option { + match self { + Self::Nip42 { owner_pubkey, .. } | Self::Http { owner_pubkey, .. } => *owner_pubkey, + } + } + + /// Sealed transport proof retained for protected authorization. + pub fn verified_proof(&self) -> Option<&Arc> { + match self { + Self::Nip42 { verified_proof, .. } | Self::Http { verified_proof, .. } => { + verified_proof.as_ref() + } + } + } + + /// Current direct federated evidence retained for protected authorization. + pub fn verified_assertion(&self) -> Option<&Arc> { + match self { + Self::Nip42 { + verified_assertion, .. + } + | Self::Http { + verified_assertion, .. + } => verified_assertion.as_ref(), + } + } + /// Pubkey used for principal-scoped accounting and policy lookups. pub fn principal_pubkey_bytes(&self) -> Vec { self.pubkey().to_bytes().to_vec() @@ -256,7 +298,10 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), - KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => { + KIND_NIP29_PUT_USER + | KIND_NIP29_REMOVE_USER + | KIND_NIP29_DELETE_GROUP + | KIND_NIP29_CREATE_INVITE => { Ok(Scope::AdminChannels) } // NIP-43: relay membership admin commands (9030–9032) + Buzz @@ -495,6 +540,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_NIP29_EDIT_METADATA | KIND_NIP29_DELETE_EVENT | KIND_NIP29_DELETE_GROUP + | KIND_NIP29_CREATE_INVITE | KIND_NIP29_LEAVE_REQUEST // Huddle lifecycle events + guidelines | KIND_HUDDLE_STARTED @@ -544,6 +590,18 @@ pub(crate) async fn check_channel_membership( } } +fn uses_generic_channel_write_authority(kind: u32) -> bool { + !matches!( + kind, + KIND_NIP29_JOIN_REQUEST + | KIND_NIP29_CREATE_GROUP + | KIND_STREAM_MESSAGE_EDIT + | KIND_NIP29_EDIT_METADATA + | KIND_NIP29_DELETE_EVENT + | KIND_NIP29_DELETE_GROUP + ) +} + fn check_token_channel_access(auth: &IngestAuth, channel_id: Uuid) -> Result<(), String> { if let Some(allowed) = auth.channel_ids() { if !allowed.contains(&channel_id) { @@ -862,6 +920,64 @@ async fn validate_edit_ownership( Ok(()) } +/// Repeat stream-edit target, membership, and agent-owner validation while the +/// common protected operation transaction owns all relevant database locks. +async fn validate_edit_ownership_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &Event, + state: &AppState, +) -> Result<(), String> { + let target_hex = event + .tags + .iter() + .find_map(|tag| { + (tag.kind().to_string() == "e") + .then(|| tag.content()) + .flatten() + }) + .filter(|value| { + value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) + }) + .ok_or_else(|| "missing e tag for edit target".to_string())?; + let target_bytes = + hex::decode(target_hex).map_err(|_| "invalid target event ID".to_string())?; + let target_event = buzz_db::event::get_event_by_id_tx(transaction, community_id, &target_bytes) + .await + .map_err(|error| format!("db error: {error}"))? + .ok_or_else(|| "edit target event not found".to_string())?; + + let edit_channel_id = extract_channel_id(event); + match (edit_channel_id, target_event.channel_id) { + (Some(edit_channel), Some(target_channel)) if edit_channel != target_channel => { + return Err("target event belongs to a different channel".to_string()); + } + (Some(_), None) => return Err("target event has no channel".to_string()), + _ => {} + } + + let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); + let actor = event.pubkey.to_bytes().to_vec(); + if author == actor { + if let Some(channel_id) = target_event.channel_id { + buzz_db::channel::require_channel_write_authority_tx( + transaction, + community_id, + channel_id, + &actor, + ) + .await + .map_err(|error| format!("restricted: channel authority changed: {error}"))?; + } + } else if !buzz_db::user::is_agent_owner_tx(transaction, community_id, &author, &actor) + .await + .map_err(|error| format!("db error checking agent ownership: {error}"))? + { + return Err("must be event author to edit".to_string()); + } + Ok(()) +} + /// Validate kind:45002 vote targets a forum post (45001) or comment (45003). async fn validate_forum_vote_target( community_id: CommunityId, @@ -1908,18 +2024,135 @@ async fn ingest_event_inner( ))); } + let protected_result = match auth.verified_proof() { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + Arc::clone(proof), + auth.verified_assertion().cloned(), + crate::protected_surface::event_ingest_capability(kind_u32), + stable_event_correlation(&event), + "event_ingest", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result.map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization denied: {error}" + )) + })?; + if protected.is_enforcing() + && crate::protected_surface::event_mutation_disposition(kind_u32) + != crate::protected_surface::EventMutationDisposition::TransactionalPersistence + { + return Err(IngestError::AuthFailed( + "restricted: protected event mutation unavailable".into(), + )); + } + let mut non_enforcing_postgresql_git = false; + let legacy_git_policy_guard = if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT + && !protected.is_enforcing() + { + let object_authority = state + .db + .protected_object_authority( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let repo_id = protected_git_repo_id(&event)?; + let owner = hex::encode(event.pubkey.to_bytes()); + match object_authority.state { + buzz_db::protected_visibility::ProtectedObjectAuthorityState::Legacy => { + if state + .db + .repo_publication_origin(tenant.community(), &repo_id, &owner) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))? + .as_deref() + == Some("protected_unpublished") + { + return Err(IngestError::AuthFailed( + "restricted: protected Git reservation cannot enter the legacy lane".into(), + )); + } + let guard = state + .db + .begin_legacy_visibility_write( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: legacy Git policy is fenced: {error}" + )) + })?; + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: legacy Git policy is permanently fenced: {error}" + )) + })?; + Some(guard) + } + buzz_db::protected_visibility::ProtectedObjectAuthorityState::PostgreSql => { + crate::api::git::migration::require_reconciled_authority(state, tenant) + .await + .map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: PostgreSQL Git policy is unavailable: {error}" + )) + })?; + non_enforcing_postgresql_git = true; + None + } + buzz_db::protected_visibility::ProtectedObjectAuthorityState::Importing => { + return Err(IngestError::AuthFailed( + "restricted: Git policy migration is incomplete".into(), + )); + } + } + } else { + None + }; + // Command kinds are routed AFTER signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { - return super::command_executor::handle_command(tenant, state, event, auth).await; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + return super::command_executor::handle_command(tenant, state, event, auth, &protected) + .await; } // Product feedback is sidecarred directly into its private deployment table. // It never enters ordinary event storage or subscription fan-out. if kind_u32 == KIND_PRODUCT_FEEDBACK { - super::product_feedback::handle(tenant, &event, state) - .await - .map_err(IngestError::Rejected)?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::product_feedback::handle_enforced(tenant, &event, state, &protected) + .await + .map_err(IngestError::Rejected)?; + } else { + super::product_feedback::handle(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + } // Feedback is a host-resolved, channel-less write. Although its row is // private to operator tooling rather than ordinary event reads, this is // the matching modeled success action at the ingest isolation seam. @@ -1938,9 +2171,20 @@ async fn ingest_event_inner( // report; that is tolerated because reports are non-actioning signals and // remain visible only to moderators. if kind_u32 == KIND_REPORT { - super::report::handle_report_event(tenant, &event, state) - .await - .map_err(IngestError::Rejected)?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::report::handle_report_event_enforced(tenant, &event, state, &protected) + .await + .map_err(IngestError::Rejected)?; + } else { + super::report::handle_report_event(tenant, &event, state) + .await + .map_err(IngestError::Rejected)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -1956,9 +2200,22 @@ async fn ingest_event_inner( // The handler independently checks the durable ban state before executing // any command, which also covers NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { - super::moderation_commands::handle_moderation_command(tenant, state, &event) + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + super::moderation_commands::handle_moderation_command_enforced( + tenant, state, &event, &protected, + ) .await .map_err(IngestError::Rejected)?; + } else { + super::moderation_commands::handle_moderation_command(tenant, state, &event) + .await + .map_err(IngestError::Rejected)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2129,7 +2386,7 @@ async fn ingest_event_inner( // row is missing (global event, kind:9007 pre-create) this is `None` and // fan-out performs its own fresh fail-closed lookup — `None` is never // "assume open" (fence 1). - let threaded_visibility = match (channel_id, &channel_row) { + let mut threaded_visibility = match (channel_id, &channel_row) { (Some(ch_id), Some(row)) => state .channel_visibility_cached(tenant.community(), ch_id, Some(row)) .await @@ -2149,13 +2406,7 @@ async fn ingest_event_inner( // member/open gate here lets the owning human act on private agent channels // without being a member (OQ1 decision; see validate_edit_ownership / // validate_admin_event for per-kind enforcement). - let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST - || kind_u32 == KIND_NIP29_CREATE_GROUP - || kind_u32 == KIND_STREAM_MESSAGE_EDIT - || kind_u32 == KIND_NIP29_EDIT_METADATA - || kind_u32 == KIND_NIP29_DELETE_EVENT - || kind_u32 == KIND_NIP29_DELETE_GROUP; - if !skip_membership { + if uses_generic_channel_write_authority(kind_u32) { // Spec AuthCheck (line 794): emit the verdict at the actual // call site. claimed_community comes from the event's h tag // (recorded separately to bite M2 / M8 — claim or A-host @@ -2191,9 +2442,22 @@ async fn ingest_event_inner( // gate above exempts relay-admin kinds so timed-out admins keep their // administrative capability, which leaves bans to the handler. if is_relay_admin_kind(event.kind.as_u16() as u32) { - crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if protected.is_enforcing() { + crate::handlers::relay_admin::handle_relay_admin_event_enforced( + tenant, state, &event, &protected, + ) .await .map_err(map_relay_admin_error)?; + } else { + crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) + .await + .map_err(map_relay_admin_error)?; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2238,11 +2502,69 @@ async fn ingest_event_inner( let sender_hex = event.pubkey.to_hex(); // remove_relay_member handles both the NotFound and IsOwner cases atomically. - let remove_result = state - .db - .remove_relay_member(tenant.community(), &sender_hex) - .await - .map_err(|e| IngestError::Internal(format!("database error: {e}")))?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let remove_result = if protected.is_enforcing() { + let operation_id = + crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "relay.leave.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-relay-leave-request-v1"); + request.update(event.id.as_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "relay.leave.v1", request.finalize().into()) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + ) => { + if payload.as_slice() != b"left" { + return Err(IngestError::Internal( + "error: protected relay-leave receipt is invalid".into(), + )); + } + buzz_db::relay_members::RemoveResult::Removed + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + let result = buzz_db::relay_members::remove_relay_member_tx( + operation.transaction(), + tenant.community(), + &sender_hex, + ) + .await + .map_err(|error| IngestError::Internal(format!("database error: {error}")))?; + if result == buzz_db::relay_members::RemoveResult::Removed { + operation.commit(b"left").await.map_err(|error| { + IngestError::AuthFailed(format!("restricted: {error}")) + })?; + } + result + } + } + } else { + state + .db + .remove_relay_member(tenant.community(), &sender_hex) + .await + .map_err(|e| IngestError::Internal(format!("database error: {e}")))? + }; match remove_result { buzz_db::relay_members::RemoveResult::Removed => {} @@ -2265,20 +2587,27 @@ async fn ingest_event_inner( } } - // Publish NIP-43 announcements — fire-and-forget. - if let Err(e) = - crate::handlers::side_effects::publish_nip43_member_removed(tenant, state, &sender_hex) - .await - { - warn!(error = %e, "failed to publish NIP-43 member removed event"); - } - if let Err(e) = - crate::handlers::side_effects::publish_nip43_membership_list(tenant, state).await - { - warn!(error = %e, "failed to publish NIP-43 membership list"); + // Relay-signed announcements are derived background effects. Preserve + // them in legacy modes, but keep them unavailable before execution in + // Enforce until they have an authoritative delivery model. + if !protected.is_enforcing() { + if let Err(e) = crate::handlers::side_effects::publish_nip43_member_removed( + tenant, + state, + &sender_hex, + ) + .await + { + warn!(error = %e, "failed to publish NIP-43 member removed event"); + } + if let Err(e) = + crate::handlers::side_effects::publish_nip43_membership_list(tenant, state).await + { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } } - info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request"); + info!("relay member left via NIP-43 leave request"); return Ok(IngestResult { event_id: event_id_hex, @@ -2298,9 +2627,16 @@ async fn ingest_event_inner( // NIP-43 admin commands above — the request itself falls through to normal // storage so the delta's `["e", request_id]` audit reference resolves. if is_identity_archive_request_kind(kind_u32) { - crate::handlers::identity_archive::handle_identity_archive_event(tenant, state, &event) - .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + if !protected.is_enforcing() { + crate::handlers::identity_archive::handle_identity_archive_event(tenant, state, &event) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } } if kind_u32 == KIND_DELETION { @@ -2479,50 +2815,57 @@ async fn ingest_event_inner( IngestError::Rejected(format!("invalid channel_type: {channel_type_str}")) })?; - if let Some(client_uuid) = channel_id { - let name = create_name.unwrap_or_default(); - let name = buzz_core::channel::canonical_channel_name(&name); + if !protected.is_enforcing() { + if let Some(client_uuid) = channel_id { + let name = create_name.unwrap_or_default(); + let name = buzz_core::channel::canonical_channel_name(&name); - let description = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "about" { - t.content().map(|s| s.to_string()) - } else { - None - } - }); + let description = event.tags.iter().find_map(|t| { + if t.kind().to_string() == "about" { + t.content().map(|s| s.to_string()) + } else { + None + } + }); - let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); + let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); - let actor_bytes = event.pubkey.to_bytes().to_vec(); - let (_, was_created) = state - .db - .create_channel_with_id( - tenant.community(), - client_uuid, - name, - channel_type, - visibility, - description.as_deref(), - &actor_bytes, - ttl_seconds, + let actor_bytes = event.pubkey.to_bytes().to_vec(); + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let (_, was_created) = state + .db + .create_channel_with_id( + tenant.community(), + client_uuid, + name, + channel_type, + visibility, + description.as_deref(), + &actor_bytes, + ttl_seconds, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + + if !was_created { + return Ok(IngestResult { + event_id: event_id_hex, + accepted: false, + message: "duplicate: channel already exists".into(), + }); + } + pre_created_channel = Some(client_uuid); + metrics::counter!( + "buzz_channels_created_total", + "community" => crate::metrics::community_label(tenant.community()), + "type" => channel_type.to_string() ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; - - if !was_created { - return Ok(IngestResult { - event_id: event_id_hex, - accepted: false, - message: "duplicate: channel already exists".into(), - }); + .increment(1); } - pre_created_channel = Some(client_uuid); - metrics::counter!( - "buzz_channels_created_total", - "community" => tenant.host().to_owned(), - "type" => channel_type.to_string() - ) - .increment(1); } } @@ -2549,6 +2892,11 @@ async fn ingest_event_inner( } if kind_u32 == super::push_lease::KIND_PUSH_LEASE { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; let outcome = super::push_lease::accept(tenant, state, &event, now) .await .map_err(map_push_accept_error)?; @@ -2688,20 +3036,116 @@ async fn ingest_event_inner( // the event in the same transaction. Ordering is load-bearing: active // duplicate reactions must return before storing a duplicate kind:7 event. let thread_params = thread_meta.as_ref().map(|m| m.as_params()); - let (stored_event, was_inserted) = match state - .db - .insert_reaction_event_with_thread_metadata( - tenant.community(), - &event, - channel_id, - thread_params, - &target_id, - &actor_bytes, - emoji, - ) - .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? - { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let outcome = if protected.is_enforcing() { + let operation_id = + crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.reaction.v1", + event.id.as_bytes(), + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-reaction-request-v1"); + request.update(event.id.as_bytes()); + request.update(&target_id); + request.update(&actor_bytes); + request.update(emoji.as_bytes()); + let permit = protected + .seal_postgres_mutation( + operation_id, + "event.reaction.v1", + request.finalize().into(), + ) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay( + payload, + ) => { + let message = match payload.as_slice() { + b"inserted" => String::new(), + b"duplicate" => "duplicate: reaction already exists".to_owned(), + _ => { + return Err(IngestError::Internal( + "error: protected reaction receipt is invalid".into(), + )); + } + }; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: payload.as_slice() == b"inserted", + message, + }); + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + if let Some(channel_id) = channel_id { + buzz_db::channel::require_channel_write_authority_tx( + operation.transaction(), + tenant.community(), + channel_id, + &pubkey_bytes, + ) + .await + .map_err(map_nip29_projection_error)?; + } + let outcome = buzz_db::event::insert_reaction_event_with_thread_metadata_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + thread_params, + &target_id, + &actor_bytes, + emoji, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + let receipt: &[u8] = match &outcome { + buzz_db::ReactionEventInsertOutcome::Inserted { .. } => b"inserted", + buzz_db::ReactionEventInsertOutcome::Duplicate => b"duplicate", + buzz_db::ReactionEventInsertOutcome::TargetMissing => { + return Err(IngestError::Rejected( + "invalid: reaction target event not found".into(), + )); + } + }; + operation + .commit(receipt) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + outcome + } + } + } else { + state + .db + .insert_reaction_event_with_thread_metadata( + tenant.community(), + &event, + channel_id, + thread_params, + &target_id, + &actor_bytes, + emoji, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))? + }; + let (stored_event, was_inserted) = match outcome { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected( "invalid: reaction target event not found".into(), @@ -2759,7 +3203,253 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + protected.revalidate().map_err(|error| { + IngestError::AuthFailed(format!( + "restricted: protected authorization expired: {error}" + )) + })?; + let mut enforced_nip29_outcome = None; + let (stored_event, was_inserted) = if protected.is_enforcing() { + let thread_params = thread_meta.as_ref().map(|metadata| metadata.as_params()); + let mut stable = Sha256::new(); + stable.update(b"buzz-event-ingest-operation-v1"); + stable.update(tenant.community().as_uuid().as_bytes()); + stable.update(event.id.as_bytes()); + let stable: [u8; 32] = stable.finalize().into(); + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "event.ingest.v1", + &stable, + ) + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let mut request = Sha256::new(); + request.update(b"buzz-event-ingest-request-v1"); + request.update(event.id.as_bytes()); + request.update(kind_u32.to_be_bytes()); + if let Some(channel_id) = channel_id { + request.update(channel_id.as_bytes()); + } + let permit = protected + .seal_postgres_mutation(operation_id, "event.ingest.v1", request.finalize().into()) + .map_err(|_| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })? + .ok_or_else(|| { + IngestError::AuthFailed("restricted: protected authorization denied".into()) + })?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + let was_inserted = match payload.as_slice() { + b"inserted" => true, + b"duplicate" => false, + _ => { + return Err(IngestError::Internal( + "error: protected event receipt is invalid".into(), + )); + } + }; + let message = if was_inserted { + String::new() + } else { + "duplicate:".to_owned() + }; + let action = match (channel_id, was_inserted) { + (Some(channel), true) => TraceAction::WriteInsert { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + }, + (Some(channel), false) => TraceAction::WriteDuplicate { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + }, + (None, _) => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed_community_from_event(&event), + }, + }; + emit(tracer, action, state_for_request(tenant, auth.pubkey())); + if let Some(outcome) = replay_nip29_outcome(kind_u32, channel_id, &event) { + apply_enforced_nip29_postcommit(tenant, state, kind_u32, &event, outcome).await; + } + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message, + }); + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + if is_identity_archive_request_kind(kind_u32) { + crate::handlers::identity_archive::handle_identity_archive_event_tx( + tenant, + state, + &event, + operation.transaction(), + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + } + if kind_u32 == KIND_STREAM_MESSAGE_EDIT { + validate_edit_ownership_tx( + operation.transaction(), + tenant.community(), + &event, + state, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + } + if let Some(channel_id) = + channel_id.filter(|_| uses_generic_channel_write_authority(kind_u32)) + { + buzz_db::channel::require_channel_write_authority_tx( + operation.transaction(), + tenant.community(), + channel_id, + &pubkey_bytes, + ) + .await + .map_err(map_nip29_projection_error)?; + } + let result = if kind_u32 == KIND_GIT_REPO_ANNOUNCEMENT { + let repo_id = protected_git_repo_id(&event)?; + buzz_db::git_repo::replace_protected_announcement_tx( + operation.transaction(), + tenant.community(), + &event, + &repo_id, + i64::from(state.config.git_max_repos_per_pubkey), + ) + .await + } else if buzz_core::kind::is_replaceable(kind_u32) { + buzz_db::event::replace_addressable_event_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + ) + .await + } else if is_parameterized_replaceable(kind_u32) { + let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); + if d_tag.len() > buzz_db::event::D_TAG_MAX_LEN { + return Err(IngestError::Rejected(format!( + "invalid: d tag too long ({} bytes, max {})", + d_tag.len(), + buzz_db::event::D_TAG_MAX_LEN, + ))); + } + buzz_db::event::replace_parameterized_event_tx( + operation.transaction(), + tenant.community(), + &event, + &d_tag, + channel_id, + ) + .await + } else { + buzz_db::event::insert_event_with_thread_metadata_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + thread_params, + ) + .await + } + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + if result.1 { + buzz_db::insert_mentions_tx( + operation.transaction(), + tenant.community(), + &event, + channel_id, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + } + if result.1 && matches!(kind_u32, KIND_PROFILE | KIND_AGENT_PROFILE) { + apply_profile_projection_tx(operation.transaction(), tenant, kind_u32, &event) + .await?; + } + if result.1 && kind_u32 == KIND_DELETION { + let actor = effective_message_author(&event, &state.relay_keypair.public_key()); + buzz_db::event::apply_standard_deletion_tx( + operation.transaction(), + tenant.community(), + &event, + &actor, + state.relay_keypair.public_key().as_bytes(), + ) + .await + .map_err(map_nip29_projection_error)?; + } + if result.1 && kind_u32 == KIND_NIP29_DELETE_EVENT { + buzz_db::event::apply_nip29_delete_event_tx( + operation.transaction(), + tenant.community(), + &event, + event.pubkey.to_bytes().as_slice(), + state.relay_keypair.public_key().as_bytes(), + channel_id.ok_or_else(|| { + IngestError::Rejected( + "invalid: channel deletion requires an h tag".into(), + ) + })?, + ) + .await + .map_err(map_nip29_projection_error)?; + } else if result.1 { + if let Some(mutation) = + protected_nip29_mutation(kind_u32, channel_id, &event, state)? + { + enforced_nip29_outcome = Some( + buzz_db::channel::apply_nip29_mutation_tx( + operation.transaction(), + tenant.community(), + event.pubkey.to_bytes().as_slice(), + mutation, + ) + .await + .map_err(map_nip29_projection_error)?, + ); + } + } + let receipt: &[u8] = if result.1 { b"inserted" } else { b"duplicate" }; + operation + .commit(receipt) + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + result + } + } + } else if non_enforcing_postgresql_git { + let repo_id = protected_git_repo_id(&event)?; + let mut transaction = state + .db + .begin_transaction() + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + let result = buzz_db::git_repo::replace_protected_announcement_tx( + &mut transaction, + tenant.community(), + &event, + &repo_id, + i64::from(state.config.git_max_repos_per_pubkey), + ) + .await + .map_err(map_nip29_projection_error)?; + transaction + .commit() + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + result + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -2826,7 +3516,10 @@ async fn ingest_event_inner( }); } - if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { + if !protected.is_enforcing() + && !non_enforcing_postgresql_git + && crate::handlers::side_effects::is_side_effect_kind(kind_u32) + { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await @@ -2839,19 +3532,34 @@ async fn ingest_event_inner( error!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}"); } } + if let Some(guard) = legacy_git_policy_guard { + guard + .commit() + .await + .map_err(|error| IngestError::AuthFailed(format!("restricted: {error}")))?; + } + + if let Some(outcome) = enforced_nip29_outcome { + apply_enforced_nip29_postcommit(tenant, state, kind_u32, &event, outcome).await; + if outcome.channel_changed { + threaded_visibility = None; + } + } // A freshly inserted reply changed its thread's counters (updated in the // same transaction as the insert) — push a fresh relay-signed 39005 so // subscribed clients can update badge counts without refetching the head // window. Page responses recompute summaries independently, so this is // fan-out-only and best-effort. - if let Some(meta) = &thread_meta { - crate::handlers::side_effects::emit_live_thread_summary( - tenant, - state, - meta.channel_id, - meta.root_event_id.clone(), - ); + if !protected.is_enforcing() { + if let Some(meta) = &thread_meta { + crate::handlers::side_effects::emit_live_thread_summary( + tenant, + state, + meta.channel_id, + meta.root_event_id.clone(), + ); + } } let pubkey_hex = auth.pubkey().to_hex(); @@ -2903,6 +3611,318 @@ async fn ingest_event_inner( }) } +fn stable_event_correlation(event: &Event) -> Uuid { + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&event.id.as_bytes()[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} + +fn replay_nip29_outcome( + kind: u32, + channel_id: Option, + event: &Event, +) -> Option { + let (channel_id, membership_changed, channel_changed) = match kind { + KIND_NIP29_CREATE_GROUP => ( + channel_id.unwrap_or_else(|| stable_event_correlation(event)), + true, + true, + ), + KIND_NIP29_PUT_USER + | KIND_NIP29_REMOVE_USER + | KIND_NIP29_JOIN_REQUEST + | KIND_NIP29_LEAVE_REQUEST => (channel_id?, true, true), + KIND_NIP29_EDIT_METADATA | KIND_NIP29_DELETE_GROUP => (channel_id?, false, true), + _ => return None, + }; + Some(buzz_db::channel::Nip29MutationOutcome { + channel_id, + changed: true, + membership_changed, + channel_changed, + }) +} + +async fn apply_enforced_nip29_postcommit( + tenant: &TenantContext, + state: &Arc, + kind: u32, + event: &Event, + outcome: buzz_db::channel::Nip29MutationOutcome, +) { + if outcome.membership_changed { + let member = match kind { + KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER => extract_p_tag_bytes(event).ok(), + KIND_NIP29_CREATE_GROUP | KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => { + Some(event.pubkey.to_bytes().to_vec()) + } + _ => None, + }; + if let Some(member) = member { + state.invalidate_membership(tenant, outcome.channel_id, &member); + if matches!(kind, KIND_NIP29_REMOVE_USER | KIND_NIP29_LEAVE_REQUEST) { + crate::handlers::side_effects::evict_live_channel_subscriptions( + tenant, + state, + outcome.channel_id, + &member, + ) + .await; + } + } + state.invalidate_all_accessible_channels(tenant); + } + if outcome.channel_changed { + state.invalidate_channel_visibility(tenant, outcome.channel_id); + if kind == KIND_NIP29_DELETE_GROUP { + state.invalidate_channel_deleted(tenant); + crate::handlers::side_effects::evict_all_channel_subscriptions( + tenant, + state, + outcome.channel_id, + ) + .await; + } + } +} + +async fn apply_profile_projection_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + kind: u32, + event: &Event, +) -> Result<(), IngestError> { + let content: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|error| IngestError::Rejected(format!("invalid: profile content: {error}")))?; + let pubkey = event.pubkey.to_bytes(); + buzz_db::user::ensure_user_tx(transaction, tenant.community(), pubkey.as_slice()) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}")))?; + if kind == KIND_AGENT_PROFILE { + let policy = content + .get("channel_add_policy") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + IngestError::Rejected("invalid: agent profile missing channel_add_policy".into()) + })?; + buzz_db::user::set_channel_add_policy_tx( + transaction, + tenant.community(), + pubkey.as_slice(), + policy, + ) + .await + .map_err(|error| IngestError::Rejected(format!("invalid: {error}")))?; + return Ok(()); + } + let display_name = content + .get("display_name") + .or_else(|| content.get("name")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let avatar_url = content + .get("picture") + .or_else(|| content.get("image")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let about = content + .get("about") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let nip05 = content + .get("nip05") + .and_then(serde_json::Value::as_str) + .and_then(|value| crate::api::nip05::canonicalize_nip05(value, tenant.host()).ok()) + .unwrap_or_default(); + buzz_db::user::replace_user_profile_tx( + transaction, + tenant.community(), + pubkey.as_slice(), + display_name, + avatar_url, + about, + &nip05, + ) + .await + .map_err(|error| IngestError::Internal(format!("error: {error}"))) +} + +fn protected_nip29_mutation( + kind: u32, + channel_id: Option, + event: &Event, + state: &Arc, +) -> Result, IngestError> { + use buzz_db::channel::{ + ChannelType, ChannelUpdate, ChannelVisibility, MemberRole, Nip29Mutation, + }; + + let tag = |name: &str| { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some(name)) + .then(|| parts.get(1).cloned()) + .flatten() + }) + }; + let required_channel = + || channel_id.ok_or_else(|| IngestError::Rejected("invalid: missing h tag".into())); + let mutation = match kind { + KIND_NIP29_CREATE_GROUP => { + let name = tag("name") + .ok_or_else(|| IngestError::Rejected("invalid: channel name is required".into()))?; + let channel_type = tag("channel_type") + .unwrap_or_else(|| "stream".into()) + .parse::() + .map_err(|_| IngestError::Rejected("invalid: channel type".into()))?; + let visibility = tag("visibility") + .unwrap_or_else(|| "open".into()) + .parse::() + .map_err(|_| IngestError::Rejected("invalid: channel visibility".into()))?; + Nip29Mutation::Create { + channel_id: channel_id.unwrap_or_else(|| stable_event_correlation(event)), + name, + channel_type, + visibility, + description: tag("about"), + ttl_seconds: super::resolve_ttl(event, state.config.ephemeral_ttl_override), + } + } + KIND_NIP29_PUT_USER => { + let target = extract_p_tag_bytes(event)?; + let role = tag("role") + .map(|role| { + role.parse::() + .map_err(|_| IngestError::Rejected("invalid: member role".into())) + }) + .transpose()?; + Nip29Mutation::PutUser { + channel_id: required_channel()?, + target, + role, + } + } + KIND_NIP29_REMOVE_USER => Nip29Mutation::RemoveUser { + channel_id: required_channel()?, + target: extract_p_tag_bytes(event)?, + }, + KIND_NIP29_EDIT_METADATA => { + let ttl_value = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("ttl")).then(|| parts.get(1).cloned()) + }); + let ttl_seconds = match ttl_value { + None => None, + Some(None) => { + return Err(IngestError::Rejected( + "invalid: channel ttl must have a value".into(), + )); + } + Some(Some(value)) if value.is_empty() => Some(None), + Some(Some(value)) => { + Some(Some(value.parse::().map_err(|_| { + IngestError::Rejected("invalid: channel ttl".into()) + })?)) + } + }; + let archived = tag("archived") + .map(|value| match value.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(IngestError::Rejected("invalid: archive state".into())), + }) + .transpose()?; + Nip29Mutation::EditMetadata { + channel_id: required_channel()?, + updates: ChannelUpdate { + name: tag("name"), + description: tag("about"), + visibility: tag("visibility"), + ttl_seconds, + }, + topic: tag("topic"), + purpose: tag("purpose"), + archived, + } + } + KIND_NIP29_DELETE_GROUP => Nip29Mutation::DeleteGroup { + channel_id: required_channel()?, + relay_pubkey: state.relay_keypair.public_key().to_bytes().to_vec(), + }, + KIND_NIP29_JOIN_REQUEST => Nip29Mutation::Join { + channel_id: required_channel()?, + }, + KIND_NIP29_LEAVE_REQUEST => Nip29Mutation::Leave { + channel_id: required_channel()?, + }, + _ => return Ok(None), + }; + Ok(Some(mutation)) +} + +fn extract_p_tag_bytes(event: &Event) -> Result, IngestError> { + let value = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| IngestError::Rejected("invalid: missing p tag".into()))?; + let bytes = + hex::decode(value).map_err(|_| IngestError::Rejected("invalid: malformed p tag".into()))?; + if bytes.len() != 32 { + return Err(IngestError::Rejected("invalid: malformed p tag".into())); + } + Ok(bytes) +} + +fn map_nip29_projection_error(error: buzz_db::DbError) -> IngestError { + match error { + buzz_db::DbError::AccessDenied(message) + | buzz_db::DbError::InvalidData(message) + | buzz_db::DbError::NotFound(message) => { + IngestError::Rejected(format!("invalid: {message}")) + } + buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::MemberNotFound(_) => { + IngestError::Rejected("invalid: channel state changed".into()) + } + other => IngestError::Internal(format!("error: {other}")), + } +} + +fn protected_git_repo_id(event: &Event) -> Result { + let repo_id = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| { + IngestError::Rejected("invalid: repository announcement missing d tag".into()) + })?; + if repo_id.is_empty() + || repo_id.len() > 64 + || repo_id.starts_with('.') + || repo_id.contains("..") + || !repo_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + { + return Err(IngestError::Rejected( + "invalid: repository identifier is not portable".into(), + )); + } + Ok(repo_id.to_owned()) +} + #[cfg(test)] mod tests { use std::sync::Mutex; @@ -2996,6 +4016,9 @@ mod tests { .expect("sign feedback"); let auth = IngestAuth::Http { pubkey: keys.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![Scope::MessagesWrite], auth_method: HttpAuthMethod::Nip98, }; @@ -3073,6 +4096,25 @@ mod tests { assert!(!requires_h_channel_scope(KIND_NIP29_CREATE_GROUP)); } + #[test] + fn protected_nip29_receipt_replay_restores_the_required_cache_fences() { + let event = make_dummy_event(); + let created = replay_nip29_outcome(KIND_NIP29_CREATE_GROUP, None, &event) + .expect("create-group receipts need replay fences"); + assert_eq!(created.channel_id, stable_event_correlation(&event)); + assert!(created.membership_changed); + assert!(created.channel_changed); + + let channel_id = Uuid::new_v4(); + let removed = replay_nip29_outcome(KIND_NIP29_REMOVE_USER, Some(channel_id), &event) + .expect("remove-user receipts need replay fences"); + assert_eq!(removed.channel_id, channel_id); + assert!(removed.membership_changed); + assert!(removed.channel_changed); + + assert!(replay_nip29_outcome(KIND_TEXT_NOTE, Some(channel_id), &event).is_none()); + } + #[test] fn join_request_does_not_require_h_tag_via_requires_h() { // kind:9021 uses h-tag for channel reference but doesn't go through @@ -3398,6 +4440,9 @@ mod tests { let envelope_signer = nostr::Keys::generate(); let auth = IngestAuth::Nip42 { pubkey: principal.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![], channel_ids: None, conn_id: Uuid::new_v4(), @@ -3416,6 +4461,9 @@ mod tests { let keys = nostr::Keys::generate(); let http_auth = IngestAuth::Http { pubkey: keys.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![], auth_method: HttpAuthMethod::Nip98, }; @@ -3431,6 +4479,9 @@ mod tests { let keys = nostr::Keys::generate(); let ws_auth = IngestAuth::Nip42 { pubkey: keys.public_key(), + owner_pubkey: None, + verified_proof: None, + verified_assertion: None, scopes: vec![], channel_ids: None, conn_id: uuid::Uuid::new_v4(), diff --git a/crates/buzz-relay/src/handlers/moderation_authz.rs b/crates/buzz-relay/src/handlers/moderation_authz.rs index 3d4b7f4a0a..8956b2ba72 100644 --- a/crates/buzz-relay/src/handlers/moderation_authz.rs +++ b/crates/buzz-relay/src/handlers/moderation_authz.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; +use sqlx::{Postgres, Transaction}; use uuid::Uuid; use crate::state::AppState; @@ -102,7 +103,7 @@ pub async fn authorize_moderation_action( // The target's community role is read only for the admin guard rail — i.e. // an admin actioning a pubkey with ban/timeout — so the owner and // channel-role paths stay at a single query. - let target_role = match (actor_role.as_deref(), action, target) { + let target_role: Option = match (actor_role.as_deref(), action, target) { (Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => { match target { ModerationTarget::Pubkey(pk) => state @@ -118,7 +119,7 @@ pub async fn authorize_moderation_action( // The channel role is read only when community authority does not apply and // the action is channel-local (DeleteMessage/Kick within `channel_id`). - let channel_role = match (actor_role.as_deref(), action, channel_id) { + let channel_role: Option = match (actor_role.as_deref(), action, channel_id) { (Some("owner") | Some("admin"), _, _) => None, (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { state @@ -137,6 +138,77 @@ pub async fn authorize_moderation_action( ) } +/// Revalidate role authority under locks owned by the caller's PostgreSQL +/// authorization transaction. +pub async fn authorize_moderation_action_tx( + transaction: &mut Transaction<'_, Postgres>, + tenant: &TenantContext, + actor_pubkey: &[u8], + channel_id: Option, + target: ModerationTarget<'_>, + action: ModerationAction, +) -> anyhow::Result { + // Moderation is rare. Table SHARE locks close the absent-row race as well + // as update/delete races: every role insert/update/delete takes the + // conflicting ROW EXCLUSIVE lock before it can commit. + sqlx::query("LOCK TABLE relay_members IN SHARE MODE") + .execute(&mut **transaction) + .await?; + if matches!( + action, + ModerationAction::DeleteMessage | ModerationAction::Kick + ) { + sqlx::query("LOCK TABLE channel_members IN SHARE MODE") + .execute(&mut **transaction) + .await?; + } + let community = tenant.community(); + let actor_role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(hex::encode(actor_pubkey)) + .fetch_optional(&mut **transaction) + .await?; + let target_role: Option = match (actor_role.as_deref(), action, target) { + ( + Some("admin"), + ModerationAction::Ban | ModerationAction::Timeout, + ModerationTarget::Pubkey(target), + ) => { + sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(hex::encode(target)) + .fetch_optional(&mut **transaction) + .await? + } + _ => None, + }; + let channel_role: Option = match (actor_role.as_deref(), action, channel_id) { + (Some("owner") | Some("admin"), _, _) => None, + (_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => { + sqlx::query_scalar( + "SELECT role::text FROM channel_members WHERE community_id = $1 \ + AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(actor_pubkey) + .fetch_optional(&mut **transaction) + .await? + } + _ => None, + }; + decide_authority( + actor_role.as_deref(), + target_role.as_deref(), + channel_role.as_deref(), + action, + ) +} + /// Pure authorization decision from resolved roles — the policy, factored out /// of the I/O so it is exhaustively unit-testable. /// diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index c769ac3cb9..1dd66920f2 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -65,6 +65,7 @@ use buzz_core::kind::{ use buzz_core::tenant::TenantContext; use chrono::{DateTime, TimeZone, Utc}; use nostr::Event; +use sha2::{Digest, Sha256}; use tracing::info; use uuid::Uuid; @@ -132,6 +133,495 @@ pub async fn handle_moderation_command( } } +/// Execute a moderation command in the protected PostgreSQL authorization +/// transaction. Durable moderation state, audit state, and the idempotency +/// receipt commit together; notices and disconnects are derived delivery after +/// that authoritative commit. +pub async fn handle_moderation_command_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let actor = event.pubkey.to_bytes().to_vec(); + validate_command_admission(tenant, state, event, &actor).await?; + let command = ProtectedModerationCommand::parse(event)?; + command.authorize(tenant, state, &actor).await?; + + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "moderation.command.v1", + event.id.as_bytes(), + ) + .map_err(|execution_error| error(execution_error.to_string()))?; + let mut request = Sha256::new(); + request.update(b"buzz-moderation-command-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = authority + .seal_postgres_mutation( + operation_id, + "moderation.command.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + + let post_commit = + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|execution_error| format!("restricted: {execution_error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"moderated" { + return Err(error("protected moderation receipt is invalid")); + } + None + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + command + .authorize_tx(operation.transaction(), tenant, &actor) + .await?; + let post_commit = command + .execute(operation.transaction(), tenant, &actor) + .await?; + operation + .commit(b"moderated") + .await + .map_err(|execution_error| format!("restricted: {execution_error}"))?; + Some(post_commit) + } + }; + + if let Some(post_commit) = post_commit { + post_commit.deliver_enforced(tenant, state, event).await; + } + Ok(()) +} + +async fn validate_command_admission( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let restriction = state + .db + .moderation_restriction_state(tenant.community(), actor) + .await + .map_err(|e| error(format!("database error checking restriction state: {e}")))?; + ensure_actor_not_banned(&restriction)?; + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { + return Err(invalid(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + event_ts - now + ))); + } + Ok(()) +} + +enum ProtectedModerationCommand { + Ban { + target: Vec, + expires_at: Option>, + reason: Option, + }, + Unban { + target: Vec, + }, + Timeout { + target: Vec, + muted_until: DateTime, + reason: Option, + }, + Untimeout { + target: Vec, + }, + Resolve { + report_event_id: Vec, + status: String, + action: String, + reason: Option, + }, +} + +impl ProtectedModerationCommand { + fn parse(event: &Event) -> Result { + match event.kind.as_u16() as u32 { + KIND_MODERATION_BAN => Ok(Self::Ban { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + expires_at: extract_expiration(event)?, + reason: extract_tag_value(event, "reason"), + }), + KIND_MODERATION_UNBAN => Ok(Self::Unban { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + }), + KIND_MODERATION_TIMEOUT => Ok(Self::Timeout { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + muted_until: extract_expiration(event)? + .ok_or_else(|| invalid("timeout requires an expiration tag"))?, + reason: extract_tag_value(event, "reason"), + }), + KIND_MODERATION_UNTIMEOUT => Ok(Self::Untimeout { + target: extract_p_tag_bytes(event) + .ok_or_else(|| invalid("missing or invalid p tag"))?, + }), + KIND_MODERATION_RESOLVE_REPORT => { + let report_event_id = extract_report_tag(event).ok_or_else(|| { + invalid("missing or invalid report tag (expect 64-hex event id)") + })?; + let status = extract_tag_value(event, "status") + .ok_or_else(|| invalid("missing status tag"))?; + let action = extract_tag_value(event, "action") + .ok_or_else(|| invalid("missing action tag"))?; + validate_resolution(&status, &action)?; + Ok(Self::Resolve { + report_event_id, + status, + action, + reason: extract_tag_value(event, "reason"), + }) + } + other => Err(invalid(format!( + "unexpected moderation command kind: {other}" + ))), + } + } + + async fn authorize( + &self, + tenant: &TenantContext, + state: &Arc, + actor: &[u8], + ) -> Result<(), String> { + let (target, action) = match self { + Self::Ban { target, .. } => (ModerationTarget::Pubkey(target), ModerationAction::Ban), + Self::Unban { target } => (ModerationTarget::Pubkey(target), ModerationAction::Unban), + Self::Timeout { target, .. } => { + (ModerationTarget::Pubkey(target), ModerationAction::Timeout) + } + Self::Untimeout { target } => ( + ModerationTarget::Pubkey(target), + ModerationAction::Untimeout, + ), + Self::Resolve { + report_event_id, .. + } => ( + ModerationTarget::Event(report_event_id), + ModerationAction::ResolveReport, + ), + }; + authorize_moderation_action(tenant, state, actor, None, target, action) + .await + .map(|_| ()) + .map_err(authz_denial) + } + + async fn authorize_tx( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + actor: &[u8], + ) -> Result<(), String> { + let (target, action) = match self { + Self::Ban { target, .. } => (ModerationTarget::Pubkey(target), ModerationAction::Ban), + Self::Unban { target } => (ModerationTarget::Pubkey(target), ModerationAction::Unban), + Self::Timeout { target, .. } => { + (ModerationTarget::Pubkey(target), ModerationAction::Timeout) + } + Self::Untimeout { target } => ( + ModerationTarget::Pubkey(target), + ModerationAction::Untimeout, + ), + Self::Resolve { + report_event_id, .. + } => ( + ModerationTarget::Event(report_event_id), + ModerationAction::ResolveReport, + ), + }; + super::moderation_authz::authorize_moderation_action_tx( + transaction, + tenant, + actor, + None, + target, + action, + ) + .await + .map(|_| ()) + .map_err(authz_denial) + } + + async fn execute( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant: &TenantContext, + actor: &[u8], + ) -> Result { + let community = tenant.community(); + match self { + Self::Ban { + target, + expires_at, + reason, + } => { + buzz_db::moderation::ban_member_tx( + transaction, + community, + target, + actor, + reason.as_deref(), + *expires_at, + ) + .await + .map_err(moderation_db_error)?; + insert_audit_tx( + transaction, + community, + actor, + "ban", + Some(target), + None, + reason.as_deref(), + ) + .await?; + Ok(ModerationPostCommit::Ban { + target: target.clone(), + }) + } + Self::Unban { target } => { + if !buzz_db::moderation::unban_member_tx(transaction, community, target, actor) + .await + .map_err(moderation_db_error)? + { + return Err(invalid("member is not banned")); + } + insert_audit_tx( + transaction, + community, + actor, + "unban", + Some(target), + None, + None, + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Timeout { + target, + muted_until, + reason, + } => { + buzz_db::moderation::timeout_member_tx( + transaction, + community, + target, + actor, + *muted_until, + reason.as_deref(), + ) + .await + .map_err(moderation_db_error)?; + insert_audit_tx( + transaction, + community, + actor, + "timeout", + Some(target), + None, + reason.as_deref(), + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Untimeout { target } => { + if !buzz_db::moderation::untimeout_member_tx(transaction, community, target, actor) + .await + .map_err(moderation_db_error)? + { + return Err(invalid("member is not timed out")); + } + insert_audit_tx( + transaction, + community, + actor, + "untimeout", + Some(target), + None, + None, + ) + .await?; + Ok(ModerationPostCommit::None) + } + Self::Resolve { + report_event_id, + status, + action, + reason, + } => { + let report = buzz_db::moderation::get_report_by_event_tx( + transaction, + community, + report_event_id, + ) + .await + .map_err(moderation_db_error)? + .ok_or_else(|| invalid("report not found in this community"))?; + if report.status != "open" { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + let (target_pubkey, target_event_id) = match &report.target { + buzz_db::moderation::ReportTarget::Pubkey(pubkey) => { + (Some(pubkey.as_slice()), None) + } + buzz_db::moderation::ReportTarget::Event(event_id) => { + (None, Some(event_id.as_slice())) + } + buzz_db::moderation::ReportTarget::Blob(_) => (None, None), + }; + let action_id = insert_audit_tx( + transaction, + community, + actor, + resolution_audit_action(action), + target_pubkey, + target_event_id, + reason.as_deref(), + ) + .await?; + if !buzz_db::moderation::resolve_report_tx( + transaction, + community, + report.id, + status, + actor, + Some(action_id), + ) + .await + .map_err(moderation_db_error)? + { + return Err(invalid( + "report is not open (already resolved or dismissed)", + )); + } + Ok(ModerationPostCommit::Resolve { + report_id: report.id, + status: status.clone(), + action: action.clone(), + }) + } + } + } +} + +enum ModerationPostCommit { + None, + Ban { + target: Vec, + }, + Resolve { + report_id: Uuid, + status: String, + action: String, + }, +} + +impl ModerationPostCommit { + /// Apply only effects that are safe after the transaction-owned Enforce + /// commit. Relay-signed notice delivery is intentionally unavailable here: + /// it can create or unhide a DM and persist helper events, so treating it as + /// derived delivery would reopen an unfenced protected mutation path. + async fn deliver_enforced(self, tenant: &TenantContext, state: &Arc, event: &Event) { + match self { + Self::None => {} + Self::Ban { target } => { + state.disconnect_pubkey_clusterwide( + tenant, + &target, + &event.id.to_hex(), + "blocked: you are banned from this community", + ); + } + Self::Resolve { + report_id, + status, + action, + } => { + info!(%report_id, %status, %action, "report resolved"); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn insert_audit_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community: buzz_core::CommunityId, + actor: &[u8], + action: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + public_reason: Option<&str>, +) -> Result { + buzz_db::moderation::insert_action_tx( + transaction, + community, + NewAction { + actor_pubkey: actor, + action, + target_pubkey, + target_event_id, + channel_id: None, + reason_code: None, + public_reason, + private_reason: None, + matched_principal: None, + }, + ) + .await + .map_err(|database_error| error(format!("failed to write audit row: {database_error}"))) +} + +fn moderation_db_error(database_error: buzz_db::DbError) -> String { + error(format!("database error: {database_error}")) +} + +fn validate_resolution(status: &str, action: &str) -> Result<(), String> { + if status != "resolved" && status != "dismissed" { + return Err(invalid(format!( + "invalid status: {status} (expect resolved|dismissed)" + ))); + } + if !matches!( + action, + "delete" | "kick" | "ban" | "timeout" | "dismiss" | "escalate" + ) { + return Err(invalid(format!( + "invalid action: {action} (expect delete|kick|ban|timeout|dismiss|escalate)" + ))); + } + if (action == "dismiss") != (status == "dismissed") { + return Err(invalid( + "action `dismiss` pairs only with status `dismissed`", + )); + } + Ok(()) +} + fn ensure_actor_not_banned( restriction: &buzz_db::moderation::RestrictionState, ) -> Result<(), String> { diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs index 8f57eea71f..0cbf0ae394 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -112,7 +112,7 @@ pub async fn send_moderation_notice( if was_created { metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => "dm" ) .increment(1); diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs index 92d045e194..3b77b5c8f0 100644 --- a/crates/buzz-relay/src/handlers/product_feedback.rs +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; use buzz_db::product_feedback::NewProductFeedback; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -18,6 +19,101 @@ pub async fn handle( event: &Event, state: &Arc, ) -> Result<(), String> { + let (category, tags, event_created_at) = validate(tenant, event, state).await?; + state + .db + .insert_product_feedback( + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; + + Ok(()) +} + +/// Validate and persist feedback at the transaction-owned protected commit +/// boundary. A retry observes the original receipt and never writes twice. +pub async fn handle_enforced( + tenant: &TenantContext, + event: &Event, + state: &Arc, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let (category, tags, event_created_at) = validate(tenant, event, state).await?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "product.feedback.v1", + event.id.as_bytes(), + ) + .map_err(|error| format!("error: {error}"))?; + let mut request = Sha256::new(); + request.update(b"buzz-product-feedback-request-v1"); + request.update(event.id.as_bytes()); + let permit = protected + .seal_postgres_mutation( + operation_id, + "product.feedback.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| format!("restricted: {error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"accepted" { + return Err("error: protected feedback receipt is invalid".to_string()); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + buzz_db::product_feedback::insert_tx( + operation.transaction(), + tenant.community(), + NewProductFeedback { + event_id: event.id.as_bytes(), + submitter_pubkey: &event.pubkey.to_bytes(), + category, + body: &event.content, + tags: &tags, + event_created_at, + }, + ) + .await + .map_err(|error| { + format!("error: database error inserting product feedback: {error}") + })?; + operation + .commit(b"accepted") + .await + .map_err(|error| format!("restricted: {error}"))?; + } + } + Ok(()) +} + +async fn validate<'a>( + tenant: &TenantContext, + event: &'a Event, + state: &Arc, +) -> Result< + ( + Option<&'a str>, + serde_json::Value, + chrono::DateTime, + ), + String, +> { let category = parse_category(event)?; validate_body(&event.content)?; let imeta_tags = event @@ -38,23 +134,7 @@ pub async fn handle( chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) .ok_or_else(|| "invalid: feedback timestamp is out of range".to_string())?; - state - .db - .insert_product_feedback( - tenant.community(), - NewProductFeedback { - event_id: event.id.as_bytes(), - submitter_pubkey: &event.pubkey.to_bytes(), - category, - body: &event.content, - tags: &tags, - event_created_at, - }, - ) - .await - .map_err(|e| format!("error: database error inserting product feedback: {e}"))?; - - Ok(()) + Ok((category, tags, event_created_at)) } fn serialize_tags(event: &Event) -> Result { diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516..297540292c 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use nostr::Event; +use sha2::{Digest, Sha256}; use tracing::{info, warn}; use buzz_core::kind::{ @@ -207,6 +208,218 @@ pub(super) async fn handle_relay_admin_event( .map_err(RelayAdminError::Rejected) } +/// Execute a relay-admin command inside the common protected authorization +/// transaction. Relay-signed roster announcements are deliberately not +/// emitted here: they are derived background effects and Enforce denies those +/// until they have their own authoritative model. +pub(super) async fn handle_relay_admin_event_enforced( + tenant: &TenantContext, + state: &Arc, + event: &Event, + protected: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), RelayAdminError> { + enforce_freshness(event).map_err(RelayAdminError::Rejected)?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "relay.admin.v1", + event.id.as_bytes(), + ) + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + let mut request = Sha256::new(); + request.update(b"buzz-relay-admin-request-v1"); + request.update(event.id.as_bytes()); + request.update((event.kind.as_u16() as u32).to_be_bytes()); + let permit = protected + .seal_postgres_mutation(operation_id, "relay.admin.v1", request.finalize().into()) + .map_err(|_| RelayAdminError::Rejected("protected authorization denied".into()))? + .ok_or_else(|| RelayAdminError::Rejected("protected authorization denied".into()))?; + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"applied" { + return Err(RelayAdminError::Internal( + "protected relay-admin receipt is invalid".into(), + )); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + let restriction = buzz_db::moderation::restriction_state_tx( + operation.transaction(), + tenant.community(), + &event.pubkey.to_bytes(), + ) + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + admits_relay_admin_command(&restriction)?; + execute_relay_admin_command_tx(tenant, event, operation.transaction()) + .await + .map_err(RelayAdminError::Rejected)?; + operation + .commit(b"applied") + .await + .map_err(|error| RelayAdminError::Internal(error.to_string()))?; + } + } + Ok(()) +} + +fn enforce_freshness(event: &Event) -> Result<(), String> { + let event_ts = event.created_at.as_secs() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > 120 { + return Err(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", + event_ts - now + )); + } + Ok(()) +} + +async fn execute_relay_admin_command_tx( + tenant: &TenantContext, + event: &Event, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let sender_hex = event.pubkey.to_hex(); + let sender_member = + buzz_db::relay_members::get_relay_member_tx(transaction, tenant.community(), &sender_hex) + .await + .map_err(|error| format!("database error: {error}"))?; + let sender_role = sender_member + .as_ref() + .map(|member| member.role.as_str()) + .unwrap_or(""); + + if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + let icon = extract_tag_value(event, "icon").unwrap_or_default(); + validate_workspace_icon(&icon)?; + let updated = sqlx::query("UPDATE communities SET icon = $2 WHERE id = $1") + .bind(tenant.community().as_uuid()) + .bind((!icon.is_empty()).then_some(icon.as_str())) + .execute(&mut **transaction) + .await + .map_err(|error| format!("failed to store workspace icon: {error}"))?; + if updated.rows_affected() != 1 { + return Err("community not found".into()); + } + return Ok(()); + } + + let target_hex = extract_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + match kind { + RELAY_ADMIN_ADD_MEMBER => { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + let role = extract_tag_value(event, "role").unwrap_or_else(|| "member".into()); + if role == "owner" { + return Err("invalid role: use kind:9032 to promote to owner".into()); + } + if role == "admin" && sender_role != "owner" { + return Err("actor not authorized: only owner can grant admin role".into()); + } + if role != "admin" && role != "member" { + return Err(format!("invalid role: {role}")); + } + buzz_db::relay_members::add_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + &role, + Some(&sender_hex), + ) + .await + .map_err(|error| format!("database error: {error}"))?; + } + RELAY_ADMIN_REMOVE_MEMBER => { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".into()); + } + if target_hex == sender_hex { + return Err("cannot remove yourself".into()); + } + let result = if sender_role == "admin" { + buzz_db::relay_members::remove_relay_member_if_role_tx( + transaction, + tenant.community(), + &target_hex, + "member", + ) + .await + } else { + buzz_db::relay_members::remove_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + ) + .await + } + .map_err(|error| format!("database error: {error}"))?; + match result { + RemoveResult::Removed => {} + RemoveResult::IsOwner => return Err("cannot remove the relay owner".into()), + RemoveResult::NotFound => return Err(format!("member not found: {target_hex}")), + RemoveResult::RoleMismatch => { + return Err("actor not authorized: admins can only remove members".into()) + } + } + } + RELAY_ADMIN_CHANGE_ROLE => { + if sender_role != "owner" { + return Err("actor not authorized: must be owner".into()); + } + if target_hex == sender_hex { + return Err("cannot change your own role".into()); + } + let new_role = + extract_tag_value(event, "role").ok_or_else(|| "missing role tag".to_string())?; + if new_role == "owner" { + return Err("cannot set role to owner".into()); + } + if new_role != "admin" && new_role != "member" { + return Err(format!("invalid role: {new_role}")); + } + if !buzz_db::relay_members::update_relay_member_role_tx( + transaction, + tenant.community(), + &target_hex, + &new_role, + ) + .await + .map_err(|error| format!("database error: {error}"))? + { + let exists = buzz_db::relay_members::get_relay_member_tx( + transaction, + tenant.community(), + &target_hex, + ) + .await + .map_err(|error| format!("database error: {error}"))?; + return Err(if exists.is_some() { + "cannot change the relay owner's role".into() + } else { + format!("member not found: {target_hex}") + }); + } + } + other => return Err(format!("unexpected relay admin kind: {other}")), + } + Ok(()) +} + /// Execute an already-admitted relay admin command. /// /// The handler: @@ -231,19 +444,7 @@ async fn execute_relay_admin_command( // This mirrors the NIP-42 auth event freshness check and prevents replay // of captured admin commands. The window is intentionally tight — admin // events should be freshly signed. - { - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > 120 { - return Err(format!( - "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", - event_ts - now - )); - } - } + enforce_freshness(event)?; let sender_member = state .db diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs index fccf8eb42b..4261b228e8 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; +use sha2::{Digest, Sha256}; use crate::state::AppState; @@ -46,6 +47,100 @@ pub async fn handle_report_event( event: &Event, state: &Arc, ) -> Result<(), String> { + let prepared = prepare_report(tenant, event, state).await?; + + state + .db + .insert_moderation_report( + tenant.community(), + prepared.as_new_report(event.id.as_bytes()), + ) + .await + .map_err(|e| format!("error: database error inserting report: {e}"))?; + + Ok(()) +} + +/// Persist a protected report and its authorization receipt atomically. +pub async fn handle_report_event_enforced( + tenant: &TenantContext, + event: &Event, + state: &Arc, + authority: &crate::authorization_runtime::transport::ProtectedAuthorization, +) -> Result<(), String> { + let prepared = prepare_report(tenant, event, state).await?; + let operation_id = crate::authorization_runtime::executor::ProtectedOperationId::derive( + tenant.community(), + "moderation.report.v1", + event.id.as_bytes(), + ) + .map_err(|error| format!("error: {error}"))?; + let mut request = Sha256::new(); + request.update(b"buzz-moderation-report-request-v1"); + request.update(event.id.as_bytes()); + let permit = authority + .seal_postgres_mutation( + operation_id, + "moderation.report.v1", + request.finalize().into(), + ) + .map_err(|_| "restricted: protected authorization denied".to_string())? + .ok_or_else(|| "restricted: protected authorization denied".to_string())?; + + match crate::authorization_runtime::executor::begin_authorized_operation(state, permit) + .await + .map_err(|error| format!("restricted: {error}"))? + { + crate::authorization_runtime::executor::AuthorizedOperationStart::Replay(payload) => { + if payload.as_slice() != b"reported" { + return Err("error: protected report receipt is invalid".to_string()); + } + } + crate::authorization_runtime::executor::AuthorizedOperationStart::Execute( + mut operation, + ) => { + buzz_db::moderation::insert_report_tx( + operation.transaction(), + tenant.community(), + prepared.as_new_report(event.id.as_bytes()), + ) + .await + .map_err(|error| format!("error: database error inserting report: {error}"))?; + operation + .commit(b"reported") + .await + .map_err(|error| format!("restricted: {error}"))?; + } + } + Ok(()) +} + +struct PreparedReport { + reporter_pubkey: Vec, + target: ReportTarget, + channel_id: Option, + report_type: String, + note: Option, +} + +impl PreparedReport { + fn as_new_report<'a>(&'a self, report_event_id: &'a [u8]) -> NewReport<'a> { + NewReport { + report_event_id, + reporter_pubkey: &self.reporter_pubkey, + target: self.target.clone(), + channel_id: self.channel_id, + report_type: &self.report_type, + note: self.note.as_deref(), + } + } +} + +async fn prepare_report( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> Result { let parsed = parse_report(event)?; let reporter_pubkey = event.pubkey.to_bytes(); @@ -61,36 +156,35 @@ pub async fn handle_report_event( } ParsedReportTarget::Blob { sha256, .. } => { let sha_hex = hex::encode(&sha256); - // Known Phase-1 limitation: the media sidecar API does not expose a - // cheap typed not-found vs transient-storage distinction here, so - // all lookup failures surface as a missing blob to the reporter. - state - .media_storage - .get_sidecar(tenant, &sha_hex) - .await - .map_err(|_| "invalid: report target blob not found".to_string())?; + if state.is_protected_enforcing(tenant.community()) { + state + .db + .media_publication(tenant.community(), &sha_hex) + .await + .map_err(|error| { + format!("error: database error resolving report target: {error}") + })? + .ok_or_else(|| "invalid: report target blob not found".to_string())?; + } else { + // Legacy modes preserve sidecar-authoritative resolution. + state + .media_storage + .get_sidecar(tenant, &sha_hex) + .await + .map_err(|_| "invalid: report target blob not found".to_string())?; + } (ReportTarget::Blob(sha256), None) } ParsedReportTarget::Pubkey { pubkey } => (ReportTarget::Pubkey(pubkey), None), }; - state - .db - .insert_moderation_report( - tenant.community(), - NewReport { - report_event_id: event.id.as_bytes(), - reporter_pubkey: &reporter_pubkey, - target, - channel_id, - report_type: parsed.report_type, - note: report_note(event), - }, - ) - .await - .map_err(|e| format!("error: database error inserting report: {e}"))?; - - Ok(()) + Ok(PreparedReport { + reporter_pubkey: reporter_pubkey.to_vec(), + target, + channel_id, + report_type: parsed.report_type.to_owned(), + note: report_note(event).map(ToOwned::to_owned), + }) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51..63db5a99e7 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -24,6 +24,25 @@ use crate::state::AppState; const MAX_SUBSCRIPTIONS: usize = 1024; +fn historical_event_release_fence( + state: &AppState, + conn: &ConnectionState, + channel_id: Option, + actor: &[u8], + protected: Arc, +) -> Arc { + match channel_id { + Some(channel_id) => crate::connection::queued_channel_read_authority( + state.db.clone(), + conn.tenant.community(), + channel_id, + actor.to_vec(), + Some(protected), + ), + None => crate::connection::queued_local_authority(protected), + } +} + /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. /// /// NIP-01 gives each filter its own DB query (OR semantics — see the comment at @@ -85,6 +104,38 @@ pub async fn handle_req( } }; + let protected_result = match state.conn_manager.authority_for_conn(conn_id) { + Some(proof) => { + crate::authorization_runtime::transport::authorize_session_if_configured( + &state, + proof, + state.conn_manager.federated_assertion_for_conn(conn_id), + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "ws_req", + conn_id, + conn.cancel.clone(), + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + &state, + conn.tenant.community(), + ), + }; + let protected = Arc::new(match protected_result { + Ok(authority) => authority, + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "protected REQ authorization denied"); + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization denied", + )); + conn.cancel.cancel(); + return; + } + }); + let mut accessible_channels = if filters_are_nip43_membership_only(&filters) { metrics::counter!("buzz_req_global_access_resolution_skips_total", "kind" => "13534") .increment(1); @@ -97,7 +148,10 @@ pub async fn handle_req( Ok(ids) => ids, Err(e) => { warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -151,7 +205,10 @@ pub async fn handle_req( } Err(e) => { warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); + conn.send_protected( + RelayMessage::closed(&sub_id, "error: database error"), + Arc::clone(&protected), + ); return; } } @@ -162,10 +219,10 @@ pub async fn handle_req( token_allows, db_is_member, ) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: not a channel member", - )); + conn.send_protected( + RelayMessage::closed(&sub_id, "restricted: not a channel member"), + Arc::clone(&protected), + ); return; } } @@ -226,11 +283,21 @@ pub async fn handle_req( &conn, &state, trace_state.as_ref(), + &protected, ) .await; return; } + if protected.revalidate().is_err() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization expired", + )); + conn.cancel.cancel(); + return; + } + { let mut subs = conn.subscriptions.lock().await; subs.insert(sub_id.clone(), filters.clone()); @@ -323,7 +390,7 @@ pub async fn handle_req( Ok(evs) => evs, Err(e) => { warn!(conn_id = %conn_id, sub_id = %sub_id, "Historical query failed: {e}"); - conn.send(RelayMessage::eose(&sub_id)); + conn.send_protected(RelayMessage::eose(&sub_id), Arc::clone(&protected)); return; } }; @@ -400,7 +467,22 @@ pub async fn handle_req( } let msg = RelayMessage::event(&sub_id, &stored.event); - if !conn.send(msg) { + if protected.revalidate().is_err() { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: protected authorization expired", + )); + conn.cancel.cancel(); + return; + } + let release = historical_event_release_fence( + &state, + &conn, + stored.channel_id, + &pubkey_bytes, + Arc::clone(&protected), + ); + if !conn.send_guarded(msg, release) { return; } total_sent += 1; @@ -410,7 +492,11 @@ pub async fn handle_req( } } - conn.send(RelayMessage::eose(&sub_id)); + if protected.revalidate().is_ok() { + conn.send_protected(RelayMessage::eose(&sub_id), Arc::clone(&protected)); + } else { + conn.cancel.cancel(); + } debug!( conn_id = %conn_id, @@ -532,6 +618,7 @@ async fn handle_search_req( conn: &ConnectionState, state: &AppState, trace_state: Option<&crate::conformance::AbstractState>, + protected: &Arc, ) { // The community-wide channel scope (no #h tag on the filter). `None` means // "no accessible channels and no global access" → EOSE, exactly as the @@ -540,7 +627,7 @@ async fn handle_search_req( match build_search_channel_scope_filter(accessible_channels, include_global) { Some(scope) => scope, None => { - conn.send(RelayMessage::eose(sub_id)); + conn.send_protected(RelayMessage::eose(sub_id), Arc::clone(protected)); return; } }; @@ -730,7 +817,18 @@ async fn handle_search_req( if !seen_ids.insert(stored.event.id) { continue; } - if !conn.send(RelayMessage::event(sub_id, &stored.event)) { + if protected.revalidate().is_err() { + conn.cancel.cancel(); + return; + } + let release = historical_event_release_fence( + state, + conn, + stored.channel_id, + reader_pubkey_bytes, + Arc::clone(protected), + ); + if !conn.send_guarded(RelayMessage::event(sub_id, &stored.event), release) { return; } emitted += 1; @@ -743,7 +841,11 @@ async fn handle_search_req( } } - conn.send(RelayMessage::eose(sub_id)); + if protected.revalidate().is_ok() { + conn.send_protected(RelayMessage::eose(sub_id), Arc::clone(protected)); + } else { + conn.cancel.cancel(); + } } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..641c35e2be 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -36,7 +36,7 @@ pub fn is_side_effect_kind(kind: u32) -> bool { matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) } -async fn evict_live_channel_subscriptions( +pub(crate) async fn evict_live_channel_subscriptions( tenant: &TenantContext, state: &Arc, channel_id: Uuid, @@ -78,7 +78,6 @@ async fn disable_departed_member_workflows( Ok(n) => { tracing::info!( channel = %channel_id, - owner = %hex::encode(target_pubkey), disabled = n, "Disabled departed member's workflows" ); @@ -89,7 +88,6 @@ async fn disable_departed_member_workflows( Err(e) => { warn!( channel = %channel_id, - owner = %hex::encode(target_pubkey), error = %e, "Failed to disable departed member's workflows — per-fire authority gate still denies" ); @@ -1179,7 +1177,7 @@ async fn handle_agent_profile( { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } @@ -1188,7 +1186,7 @@ async fn handle_agent_profile( .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) .await?; - info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); + info!(policy, "kind:10100 channel_add_policy updated"); Ok(()) } @@ -1237,7 +1235,7 @@ async fn handle_kind0_profile( { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } @@ -1261,8 +1259,7 @@ async fn handle_kind0_profile( if let Err(ref e) = result { let msg = format!("{e}"); if msg.contains("duplicate key value") || msg.contains("23505") { - warn!(pubkey = %hex::encode(&pubkey_bytes), - "kind:0 NIP-05 handle contested, syncing profile without it"); + warn!("kind:0 NIP-05 handle contested, syncing profile without it"); state .db .update_user_profile( @@ -1279,7 +1276,7 @@ async fn handle_kind0_profile( } } - info!(pubkey = %hex::encode(&pubkey_bytes), "kind:0 profile synced to users table"); + info!("kind:0 profile synced to users table"); Ok(()) } @@ -1807,7 +1804,7 @@ async fn handle_create_group( .await?; metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => channel_type.to_string() ) .increment(1); @@ -1829,7 +1826,7 @@ async fn handle_create_group( .await?; metrics::counter!( "buzz_channels_created_total", - "community" => tenant.host().to_owned(), + "community" => crate::metrics::community_label(tenant.community()), "type" => channel_type.to_string() ) .increment(1); @@ -2520,6 +2517,24 @@ async fn handle_git_repo_announcement( event: &Event, state: &Arc, ) -> anyhow::Result<()> { + // Enforce announcements reserve their name and replace the NIP-33 event in + // the authorization-owned PostgreSQL transaction. PostgreSQL starts them + // unpublished; the first authorized push publishes the immutable manifest. + // Running the legacy pointer path here would be an unfenced dual write. + if state.is_protected_enforcing(tenant.community()) { + return Ok(()); + } + // The transaction share-lock spans both the name reservation and the + // object-store pointer write. Cutover takes the conflicting row lock, so + // it cannot enter `importing` while a final legacy publication is in flight. + let legacy_visibility = state + .db + .begin_legacy_visibility_write( + tenant.community(), + buzz_db::protected_visibility::ProtectedObjectSurface::Git, + ) + .await?; + crate::api::git::migration::require_legacy_sentinel_absent(state, tenant).await?; // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). let repo_id = extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; @@ -2664,10 +2679,10 @@ async fn handle_git_repo_announcement( "failed to ensure manifest pointer: {pointer_err}" )); } + legacy_visibility.commit().await?; info!( repo_id = %repo_id, - owner = %owner_hex, reserved = reserved_by_this_attempt, "kind:30617 repo announced (name reserved, manifest pointer ensured)" ); @@ -2691,7 +2706,6 @@ async fn handle_git_repo_announcement( // "repo now exists" event, but clone/push still works. warn!( repo_id = %repo_id, - owner = %owner_hex, error = %e, "failed to emit initial kind:30618 ref state (non-fatal)" ); @@ -2885,6 +2899,9 @@ pub async fn reconcile_nip43_membership_snapshots(state: &Arc) -> anyh for community in communities { let community_id = buzz_core::CommunityId::from_uuid(community.id); + if state.is_protected_enforcing(community_id) { + continue; + } let host = community.host; let result = async { if !state @@ -3057,6 +3074,10 @@ pub async fn reconcile_channel_events( ) -> anyhow::Result<()> { use buzz_db::event::EventQuery; + if state.is_protected_enforcing(tenant.community()) { + return Ok(()); + } + let channels = state.db.list_channels(tenant.community(), None).await?; if channels.is_empty() { return Ok(()); diff --git a/crates/buzz-relay/src/protected_surface.rs b/crates/buzz-relay/src/protected_surface.rs new file mode 100644 index 0000000000..6b840f38e5 --- /dev/null +++ b/crates/buzz-relay/src/protected_surface.rs @@ -0,0 +1,1780 @@ +//! Provider-neutral inventory of relay authorization surfaces. +//! +//! This is the single reviewable registry for HTTP routes and long-lived +//! protocol operations. It records both protected operations and deliberate +//! exemptions. Runtime code derives the requested portable capability from +//! this module; request data never selects a provider profile or policy. + +use axum::http::Method; +use buzz_auth::{AuthTransport, AuthorizationCapability}; + +use crate::authorization_runtime::finalization::AuthorizationMode; + +/// Closed identifier for every backend-visible effect family. +/// +/// Adding an effect requires adding a registry row and choosing an explicit +/// Enforce disposition. Dynamic helper names cannot manufacture a permit. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectSurfaceId { + /// Durable Nostr event storage. + EventPersistence, + /// Channel, member, profile, reaction, moderation, or deletion projection. + EventDomainProjection, + /// Invitation creation. + InviteMint, + /// Invitation consumption and final membership creation. + InviteClaim, + /// PostgreSQL-authoritative media visibility. + MediaPublication, + /// PostgreSQL-authoritative Git ref visibility. + GitPublication, + /// Existing-member audio session admission. + AudioAdmission, + /// Legacy automatic audio membership creation. + AudioAutomaticMembership, + /// Durable audio lifecycle event persistence. + AudioLifecyclePersistence, + /// Automatic last-participant channel archival. + AudioAutomaticArchive, + /// Interactive workflow definition or state mutation. + WorkflowStateMutation, + /// Autonomous or delayed workflow execution. + WorkflowBackgroundExecution, + /// Arbitrary outbound HTTP webhook. + OutboundWebhook, + /// Any helper that has not been assigned a closed effect identifier. + UnclassifiedHelper, + /// Recipient-fenced local WebSocket delivery. + LocalFanout, + /// Recipient-fenced Redis delivery hint. + RedisFanout, + /// Redis-backed presence visible only through retained authority. + ProtectedPresence, + /// Persistent relay-signed helper event. + RelaySignedHelperEvent, + /// External push delivery. + PushDelivery, + /// Legacy best-effort audit-channel delivery; O5 owns durable audit. + LegacyAuditDelivery, + /// Cache eviction or connection cancellation derived from a commit. + CacheAndConnectionInvalidation, + /// Repairable legacy media sidecar written after authoritative publication. + MediaLegacySidecar, + /// Legacy moderation upload record emitted by object-store creation. + MediaUploadRecord, + /// Repairable legacy Git pointer written after authoritative publication. + GitLegacyPointer, + /// Durable invalidation polling and reconciliation. + AuthorizationReconciliation, + /// Durable public-assertion retirement derived from committed identity lifecycle state. + PublicProjectionRetirement, + /// Retryable local and cross-replica delivery of a committed public retirement. + PublicProjectionRetirementDelivery, + /// Dedicated exact-connection delivery of current binding status or withdrawal. + ClientStatusDelivery, + /// Storage garbage collection. + StorageGarbageCollection, + /// Reminder claim or publication. + ReminderWorker, + /// Push matching or delivery worker. + PushWorker, + /// Partition, expiry, or retention maintenance. + DatabaseMaintenance, + /// Audio mesh ownership maintenance. + AudioMeshMaintenance, + /// Metrics-only observation. + MetricsObservation, +} + +/// Effect relationship to protected business state. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectClass { + /// The effect is the durable source of protected state or visibility. + AuthoritativeMutation, + /// The effect is derived from an already-committed authoritative result. + DerivedDelivery, + /// The effect is server-owned maintenance rather than user authority. + SystemMaintenance, +} + +/// Code location category used by inventory coverage checks. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EffectOrigin { + /// HTTP route. + Http, + /// WebSocket operation. + WebSocket, + /// Shared helper invoked from more than one route. + Helper, + /// Autonomous or delayed background task. + Background, +} + +/// Why an effect is deliberately unavailable in Enforce. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum UnavailableReason { + /// No transaction-owned authorization permit is retained. + MissingTransactionAuthority, + /// No reviewed autonomous/system authority model exists. + MissingBackgroundAuthority, + /// The target cannot provide an authoritative idempotent commit boundary. + MissingExternalCommitPrimitive, + /// The legacy behavior would create membership implicitly. + AutomaticMembershipForbidden, + /// The effect has not been classified and registered. + UnclassifiedEffect, +} + +/// Enforce behavior selected for a registered effect. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum EnforceDisposition { + /// The effect has the required authoritative or release primitive. + Supported, + /// Deny synchronously before the effect begins. + DenyBeforeEffect(UnavailableReason), +} + +/// One machine-readable effect registry row. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EffectSurface { + /// Closed effect identifier. + pub id: EffectSurfaceId, + /// Stable provider-neutral inventory label. + pub name: &'static str, + /// Relationship to protected state. + pub class: EffectClass, + /// Code location category. + pub origin: EffectOrigin, + /// Portable capability, when the effect acts for a user operation. + pub capability: Option, + /// Enforce behavior. + pub enforce: EnforceDisposition, + /// Mandatory lifetime checkpoints. + pub guard_points: &'static [GuardPoint], +} + +/// Enforce implementation selected for one authenticated EVENT kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventMutationDisposition { + /// The event and thread metadata use the common PostgreSQL executor. + TransactionalPersistence, + /// The kind requires a projection that has no transaction-aware adapter. + UnavailableProjection, + /// The kind enters a command/workflow executor without a shared commit. + UnavailableCommandOrWorkflow, +} + +/// Classify every EVENT kind before any Enforce mutation begins. +/// +/// Unknown kinds still fail in the ingest allowlist. This function only +/// chooses the commit primitive for kinds that pass normal protocol checks. +pub fn event_mutation_disposition(kind: u32) -> EventMutationDisposition { + use buzz_core::kind::*; + + if buzz_core::kind::is_moderation_command_kind(kind) + || matches!(kind, KIND_REPORT | KIND_GIT_REPO_ANNOUNCEMENT) + { + return EventMutationDisposition::TransactionalPersistence; + } + if matches!( + kind, + KIND_WORKFLOW_DEF + | KIND_WORKFLOW_TRIGGER + | KIND_APPROVAL_GRANT + | KIND_APPROVAL_DENY + | KIND_PUSH_LEASE + ) { + return EventMutationDisposition::UnavailableCommandOrWorkflow; + } + if matches!( + kind, + KIND_DM_OPEN + | KIND_DM_ADD_MEMBER + | KIND_DM_HIDE + | KIND_PRODUCT_FEEDBACK + | KIND_NIP43_LEAVE_REQUEST + | KIND_NIP29_CREATE_INVITE + ) || buzz_core::kind::is_relay_admin_kind(kind) + || buzz_core::kind::is_identity_archive_request_kind(kind) + { + return EventMutationDisposition::TransactionalPersistence; + } + if matches!( + kind, + 9003..=9004 + | 9006 + | 9010..=9020 + | 41001..=41003 + | 40099 + ) { + return EventMutationDisposition::UnavailableProjection; + } + EventMutationDisposition::TransactionalPersistence +} + +/// Recheck the stable handler surface, proof transport, and selected portable +/// capability before a resolver can observe the request. +pub fn protected_operation_matches( + surface: &str, + transport: AuthTransport, + capability: AuthorizationCapability, +) -> bool { + use AuthorizationCapability as Capability; + match surface { + "ws_req" | "ws_count" | "ws_fanout" | "client.status.current" => { + transport == AuthTransport::RelayWebSocket && capability == Capability::CommunityRead + } + "ws_event" => { + transport == AuthTransport::RelayWebSocket + && matches!( + capability, + Capability::CommunityWrite | Capability::Moderate + ) + } + "event_ingest" => { + matches!( + transport, + AuthTransport::RelayWebSocket | AuthTransport::HttpBridge + ) && matches!( + capability, + Capability::CommunityWrite | Capability::Moderate + ) + } + "http_events" => { + transport == AuthTransport::HttpBridge + && matches!( + capability, + Capability::CommunityWrite | Capability::Moderate + ) + } + "http_query" | "http_count" => { + transport == AuthTransport::HttpBridge && capability == Capability::CommunityRead + } + "http_moderation_read" => { + transport == AuthTransport::HttpBridge && capability == Capability::Moderate + } + "media.upload" => { + transport == AuthTransport::MediaUpload && capability == Capability::MediaWrite + } + "media.read" => { + transport == AuthTransport::MediaDownload && capability == Capability::MediaRead + } + "git.info_refs" => { + transport == AuthTransport::Git + && matches!(capability, Capability::GitRead | Capability::GitWrite) + } + "git.upload_pack" => transport == AuthTransport::Git && capability == Capability::GitRead, + "git.receive_pack" => transport == AuthTransport::Git && capability == Capability::GitWrite, + "audio.join" => transport == AuthTransport::Audio && capability == Capability::AudioJoin, + "invite.mint" => { + transport == AuthTransport::HttpBridge && capability == Capability::InviteMint + } + "invite.claim" => { + transport == AuthTransport::HttpBridge && capability == Capability::InviteClaim + } + _ => false, + } +} + +/// Why a registered surface is deliberately outside tenant authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum SurfaceExemption { + /// Public relay metadata (NIP-05 and NIP-11-adjacent information). + PublicMetadata, + /// Kubernetes or service health endpoint. + HealthProbe, + /// Public pre-membership policy bootstrap. + JoinBootstrap, + /// Deployment-global operator authentication. + OperatorAuth, + /// Deployment-admin host/session authentication. + AdminAuth, + /// Loopback-only, HMAC-authenticated Git hook callback. + LocalHookCallback, + /// Disabled-by-default mesh testbed endpoint. + TestbedOnly, + /// Static UI fallback that cannot reach an API handler. + StaticUiFallback, +} + +/// How a registered surface participates in protected authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SurfaceProtection { + /// One fixed portable capability is required for the request. + Capability(AuthorizationCapability), + /// The request body or protocol operation determines the capability. + DynamicCapability, + /// A fixed capability committed through an authoritative transaction/CAS. + AtomicMutation(AuthorizationCapability), + /// A dynamically selected mutation committed through an authoritative executor. + DynamicAtomicMutation, + /// A fixed capability whose Enforce path remains unavailable until a + /// backend-specific transaction/CAS executor owns the complete commit. + AtomicMutationUnavailable(AuthorizationCapability), + /// A dynamically selected mutation capability with the same fail-closed + /// backend-executor requirement. + DynamicAtomicMutationUnavailable, + /// Authentication is completed after the HTTP upgrade. + Session { + /// Fixed capability for the session, or `None` for per-operation WS + /// authorization after NIP-42 AUTH. + capability: Option, + }, + /// A long-lived session whose admission mutates protected state and is + /// therefore unavailable in Enforce without an atomic backend executor. + AtomicMutationSessionUnavailable { + /// Fixed capability required by the session admission. + capability: AuthorizationCapability, + }, + /// A non-persistent session admitted under a bounded lease and cancellation fence. + LeasedSession { + /// Fixed capability required by the session admission. + capability: AuthorizationCapability, + }, + /// Plain GET/HEAD is public metadata; a WebSocket upgrade enters protected + /// per-operation session authorization. + ConditionalWebSocketUpgrade, + /// Protection depends on the server-owned media-read setting. + ConditionalMediaRead(AuthorizationCapability), + /// Deliberately outside tenant protected authorization. + Exempt(SurfaceExemption), +} + +impl SurfaceProtection { + /// Stable low-cardinality label for tracing and inventory exports. + pub const fn trace_label(self) -> &'static str { + match self { + Self::Capability(_) => "required", + Self::DynamicCapability => "required_dynamic_capability", + Self::AtomicMutation(_) => "required_atomic_commit", + Self::DynamicAtomicMutation => "required_dynamic_atomic_commit", + Self::AtomicMutationUnavailable(_) => "enforce_unavailable_without_atomic_executor", + Self::DynamicAtomicMutationUnavailable => { + "enforce_unavailable_without_dynamic_atomic_executor" + } + Self::Session { .. } => "required_at_session_auth", + Self::AtomicMutationSessionUnavailable { .. } => { + "enforce_session_unavailable_without_atomic_executor" + } + Self::LeasedSession { .. } => "required_leased_session", + Self::ConditionalWebSocketUpgrade => "required_on_websocket_upgrade", + Self::ConditionalMediaRead(_) => "required_when_media_reads_protected", + Self::Exempt(SurfaceExemption::PublicMetadata) => "exempt_public_metadata", + Self::Exempt(SurfaceExemption::HealthProbe) => "exempt_health_probe", + Self::Exempt(SurfaceExemption::JoinBootstrap) => "exempt_join_bootstrap", + Self::Exempt(SurfaceExemption::OperatorAuth) => "exempt_operator_auth", + Self::Exempt(SurfaceExemption::AdminAuth) => "exempt_admin_auth", + Self::Exempt(SurfaceExemption::LocalHookCallback) => "exempt_local_hook_callback", + Self::Exempt(SurfaceExemption::TestbedOnly) => "exempt_testbed_only", + Self::Exempt(SurfaceExemption::StaticUiFallback) => "exempt_static_ui_fallback", + } + } +} + +/// Required lifetime checkpoints for a protected operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum GuardPoint { + /// Validate before handler work begins. + Request, + /// Revalidate before a durable or externally visible mutation commits. + PreCommit, + /// Revalidate after asynchronous fetches and before buffered output is released. + PreEmission, + /// Revalidate before each streamed chunk or live event emission. + StreamEmission, + /// Renew or close a long-lived session before its lease expires. + SessionRenewal, +} + +const REQUEST: &[GuardPoint] = &[GuardPoint::Request]; +const REQUEST_COMMIT: &[GuardPoint] = &[GuardPoint::Request, GuardPoint::PreCommit]; +const REQUEST_EMIT: &[GuardPoint] = &[GuardPoint::Request, GuardPoint::PreEmission]; +const REQUEST_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::PreEmission, + GuardPoint::StreamEmission, +]; +const SESSION_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::SessionRenewal, + GuardPoint::StreamEmission, +]; +const SESSION_COMMIT_STREAM: &[GuardPoint] = &[ + GuardPoint::Request, + GuardPoint::PreCommit, + GuardPoint::PreEmission, + GuardPoint::SessionRenewal, + GuardPoint::StreamEmission, +]; + +const fn effect( + id: EffectSurfaceId, + name: &'static str, + class: EffectClass, + origin: EffectOrigin, + capability: Option, + enforce: EnforceDisposition, + guard_points: &'static [GuardPoint], +) -> EffectSurface { + EffectSurface { + id, + name, + class, + origin, + capability, + enforce, + guard_points, + } +} + +/// Exhaustive provider-neutral inventory of backend-visible effect families. +pub const EFFECT_SURFACES: &[EffectSurface] = &[ + effect( + EffectSurfaceId::EventPersistence, + "event.persistence", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::EventDomainProjection, + "event.domain_projection", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::InviteMint, + "invite.mint_commit", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::InviteMint), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::InviteClaim, + "invite.claim_commit", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::InviteClaim), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::MediaPublication, + "media.publication", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::MediaWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::GitPublication, + "git.publication", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::GitWrite), + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioAdmission, + "audio.existing_member_admission", + EffectClass::AuthoritativeMutation, + EffectOrigin::Http, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::Supported, + SESSION_COMMIT_STREAM, + ), + effect( + EffectSurfaceId::AudioAutomaticMembership, + "audio.automatic_membership", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::AutomaticMembershipForbidden), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioLifecyclePersistence, + "audio.lifecycle_persistence", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::AudioJoin), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingTransactionAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioAutomaticArchive, + "audio.automatic_archive", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::WorkflowStateMutation, + "workflow.interactive_state", + EffectClass::AuthoritativeMutation, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityWrite), + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingTransactionAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::WorkflowBackgroundExecution, + "workflow.background_execution", + EffectClass::AuthoritativeMutation, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::OutboundWebhook, + "workflow.outbound_webhook", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingExternalCommitPrimitive), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::UnclassifiedHelper, + "background.unclassified_helper", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::UnclassifiedEffect), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::LocalFanout, + "delivery.local_fanout", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::RedisFanout, + "delivery.redis_fanout", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::ProtectedPresence, + "delivery.protected_presence", + EffectClass::DerivedDelivery, + EffectOrigin::WebSocket, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::RelaySignedHelperEvent, + "delivery.relay_signed_helper_event", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::PushDelivery, + "delivery.external_push", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingExternalCommitPrimitive), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::LegacyAuditDelivery, + "delivery.legacy_audit_channel", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::CacheAndConnectionInvalidation, + "delivery.cache_connection_invalidation", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::MediaLegacySidecar, + "delivery.media_legacy_sidecar", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::MediaUploadRecord, + "delivery.media_upload_record", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::GitLegacyPointer, + "delivery.git_legacy_pointer", + EffectClass::DerivedDelivery, + EffectOrigin::Helper, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::AuthorizationReconciliation, + "maintenance.authorization_reconciliation", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST, + ), + effect( + EffectSurfaceId::PublicProjectionRetirement, + "maintenance.public_projection_retirement", + EffectClass::AuthoritativeMutation, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::PublicProjectionRetirementDelivery, + "delivery.public_projection_retirement", + EffectClass::DerivedDelivery, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::ClientStatusDelivery, + "client.status.dedicated_delivery", + EffectClass::DerivedDelivery, + EffectOrigin::WebSocket, + Some(AuthorizationCapability::CommunityRead), + EnforceDisposition::Supported, + REQUEST_EMIT, + ), + effect( + EffectSurfaceId::StorageGarbageCollection, + "maintenance.storage_gc", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::ReminderWorker, + "maintenance.reminder_worker", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::PushWorker, + "maintenance.push_worker", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::DatabaseMaintenance, + "maintenance.database", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::DenyBeforeEffect(UnavailableReason::MissingBackgroundAuthority), + REQUEST_COMMIT, + ), + effect( + EffectSurfaceId::AudioMeshMaintenance, + "maintenance.audio_mesh", + EffectClass::SystemMaintenance, + EffectOrigin::Background, + None, + EnforceDisposition::Supported, + REQUEST, + ), + effect( + EffectSurfaceId::MetricsObservation, + "maintenance.metrics_observation", + EffectClass::SystemMaintenance, + EffectOrigin::Helper, + None, + EnforceDisposition::Supported, + REQUEST, + ), +]; + +/// Unforgeable proof that one registered effect is permitted in the selected mode. +pub struct EffectPermit { + id: EffectSurfaceId, +} + +impl EffectPermit { + /// Registered effect represented by this permit. + pub const fn id(&self) -> EffectSurfaceId { + self.id + } +} + +/// Fail-closed effect classification error. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum EffectPermitError { + /// The closed identifier has no registry row. + #[error("protected effect is not classified")] + Unclassified, + /// The registered effect is deliberately unavailable in Enforce. + #[error("protected effect is unavailable in enforce mode")] + Unavailable(UnavailableReason), +} + +/// Require a registered pre-effect permit for one exact activation mode. +pub fn require_effect_permit( + mode: Option, + id: EffectSurfaceId, +) -> Result { + let surface = EFFECT_SURFACES + .iter() + .find(|surface| surface.id == id) + .ok_or(EffectPermitError::Unclassified)?; + if mode == Some(AuthorizationMode::Enforce) { + if let EnforceDisposition::DenyBeforeEffect(reason) = surface.enforce { + return Err(EffectPermitError::Unavailable(reason)); + } + } + Ok(EffectPermit { id }) +} + +/// Resolve only a registered stable helper name; unknown names never fall back. +pub fn effect_surface_by_name(name: &str) -> Option<&'static EffectSurface> { + EFFECT_SURFACES.iter().find(|surface| surface.name == name) +} + +/// Require a registered stable effect name. Unknown helper names preserve +/// legacy modes but map to the explicit unclassified-denial row in Enforce. +pub fn require_effect_name( + mode: Option, + name: &str, +) -> Result { + let id = effect_surface_by_name(name) + .map_or(EffectSurfaceId::UnclassifiedHelper, |surface| surface.id); + require_effect_permit(mode, id) +} + +/// One registered HTTP route and its protected-authorization contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HttpSurface { + /// HTTP method as an uppercase token. + pub method: &'static str, + /// Axum matched-path template, never a literal untrusted path. + pub matched_path: &'static str, + /// Authorization classification. + pub protection: SurfaceProtection, + /// Mandatory lifetime checkpoints. + pub guard_points: &'static [GuardPoint], +} + +const fn http( + method: &'static str, + matched_path: &'static str, + protection: SurfaceProtection, + guard_points: &'static [GuardPoint], +) -> HttpSurface { + HttpSurface { + method, + matched_path, + protection, + guard_points, + } +} + +const fn exempt(reason: SurfaceExemption) -> SurfaceProtection { + SurfaceProtection::Exempt(reason) +} + +/// Exhaustive inventory of API routes registered by the relay router. +/// +/// Static UI fallbacks are recorded separately in [`NON_ROUTER_SURFACES`] +/// because they have no Axum `MatchedPath` and cannot reach an API handler. +pub const HTTP_SURFACES: &[HttpSurface] = &[ + http( + "GET", + "/", + SurfaceProtection::ConditionalWebSocketUpgrade, + SESSION_STREAM, + ), + http( + "HEAD", + "/", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/info", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/.well-known/nostr.json", + exempt(SurfaceExemption::PublicMetadata), + REQUEST, + ), + http( + "GET", + "/health", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_liveness", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_readiness", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_status", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "GET", + "/_mesh", + exempt(SurfaceExemption::HealthProbe), + REQUEST, + ), + http( + "POST", + "/events", + SurfaceProtection::DynamicAtomicMutation, + REQUEST_COMMIT, + ), + http( + "POST", + "/query", + SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + REQUEST_EMIT, + ), + http( + "POST", + "/count", + SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + REQUEST_EMIT, + ), + http( + "GET", + "/operator/communities", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/archive", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/unarchive", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "GET", + "/operator/communities/availability", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/operator/communities/transfer", + exempt(SurfaceExemption::OperatorAuth), + REQUEST, + ), + http( + "POST", + "/api/invites", + SurfaceProtection::AtomicMutation(AuthorizationCapability::InviteMint), + REQUEST_COMMIT, + ), + http( + "POST", + "/api/invites/claim", + SurfaceProtection::AtomicMutation(AuthorizationCapability::InviteClaim), + REQUEST_COMMIT, + ), + http( + "GET", + "/api/join-policy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/api/join-policy/terms", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/api/join-policy/privacy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "POST", + "/api/invites/accept-policy", + exempt(SurfaceExemption::JoinBootstrap), + REQUEST, + ), + http( + "GET", + "/moderation/reports", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "GET", + "/moderation/audit", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "GET", + "/moderation/restricted", + SurfaceProtection::Capability(AuthorizationCapability::Moderate), + REQUEST_EMIT, + ), + http( + "POST", + "/hooks/{id}", + SurfaceProtection::AtomicMutationUnavailable(AuthorizationCapability::CommunityWrite), + REQUEST_COMMIT, + ), + http( + "POST", + "/_mesh/demo/echo", + exempt(SurfaceExemption::TestbedOnly), + REQUEST, + ), + http( + "POST", + "/internal/git/policy", + exempt(SurfaceExemption::LocalHookCallback), + REQUEST, + ), + http( + "GET", + "/huddle/{channel_id}/audio", + SurfaceProtection::LeasedSession { + capability: AuthorizationCapability::AudioJoin, + }, + SESSION_COMMIT_STREAM, + ), + http( + "PUT", + "/upload", + SurfaceProtection::AtomicMutation(AuthorizationCapability::MediaWrite), + REQUEST_COMMIT, + ), + http( + "PUT", + "/media/upload", + SurfaceProtection::AtomicMutation(AuthorizationCapability::MediaWrite), + REQUEST_COMMIT, + ), + http( + "GET", + "/media/{sha256_ext}", + SurfaceProtection::ConditionalMediaRead(AuthorizationCapability::MediaRead), + REQUEST_STREAM, + ), + http( + "HEAD", + "/media/{sha256_ext}", + SurfaceProtection::ConditionalMediaRead(AuthorizationCapability::MediaRead), + REQUEST_EMIT, + ), + http( + "GET", + "/git/{owner}/{repo}/info/refs", + SurfaceProtection::DynamicCapability, + REQUEST_STREAM, + ), + http( + "POST", + "/git/{owner}/{repo}/git-upload-pack", + SurfaceProtection::Capability(AuthorizationCapability::GitRead), + REQUEST_STREAM, + ), + http( + "POST", + "/git/{owner}/{repo}/git-receive-pack", + SurfaceProtection::AtomicMutation(AuthorizationCapability::GitWrite), + REQUEST_COMMIT, + ), + http( + "GET", + "/api/admin/v1/reports", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/reports/{id}", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback/{id}", + exempt(SurfaceExemption::AdminAuth), + REQUEST, + ), + http( + "GET", + "/api/admin/v1/feedback/{id}/attachments/{sha256}", + exempt(SurfaceExemption::AdminAuth), + REQUEST_STREAM, + ), +]; + +/// Surface outside Axum's matched-route inventory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NonRouterSurface { + /// Stable surface name. + pub name: &'static str, + /// Explicit protection or exemption. + pub protection: SurfaceProtection, +} + +/// Explicit inventory for request fallbacks and other non-router surfaces. +pub const NON_ROUTER_SURFACES: &[NonRouterSurface] = &[NonRouterSurface { + name: "static_ui_fallback", + protection: SurfaceProtection::Exempt(SurfaceExemption::StaticUiFallback), +}]; + +/// Classify a registered method and trusted Axum matched-path template. +pub fn classify_http(method: &Method, matched_path: &str) -> Option<&'static HttpSurface> { + let exact = HTTP_SURFACES + .iter() + .find(|surface| surface.method == method.as_str() && surface.matched_path == matched_path); + if exact.is_some() || method != Method::HEAD { + return exact; + } + HTTP_SURFACES + .iter() + .find(|surface| surface.method == "GET" && surface.matched_path == matched_path) +} + +/// Whether any registered method names the matched template. +pub fn is_known_http_path(matched_path: &str) -> bool { + HTTP_SURFACES + .iter() + .any(|surface| surface.matched_path == matched_path) +} + +/// RFC 9110 `Allow` value for a known matched template. +pub fn allowed_http_methods(matched_path: &str) -> Option { + let mut methods = Vec::new(); + for surface in HTTP_SURFACES + .iter() + .filter(|surface| surface.matched_path == matched_path) + { + if !methods.contains(&surface.method) { + methods.push(surface.method); + } + if surface.method == "GET" && !methods.contains(&"HEAD") { + methods.push("HEAD"); + } + } + (!methods.is_empty()).then(|| methods.join(", ")) +} + +/// Protected WebSocket operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum WebSocketOperation { + /// NIP-42 session bootstrap. It establishes identity but grants no data-plane capability. + Auth, + /// Historical or live subscription creation. + Req, + /// Aggregate query. + Count, + /// Persistent or ephemeral event ingest. + Event { + /// Nostr event kind used to select write or moderation authority. + kind: u32, + }, + /// Live event delivery to a subscription. + Fanout, +} + +/// One protocol-level WebSocket operation and its release/commit contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebSocketSurface { + /// Stable low-cardinality operation label. + pub operation: &'static str, + /// Operation-level authorization classification. + pub protection: SurfaceProtection, + /// Required checks from dispatch through commit or socket emission. + pub guard_points: &'static [GuardPoint], +} + +/// Operation-level inventory for the WebSocket route. +/// +/// The HTTP `/` row describes only upgrade/session lifetime. This table keeps +/// EVENT commit fencing distinct from REQ/COUNT/fanout release fencing. +pub const WEBSOCKET_SURFACES: &[WebSocketSurface] = &[ + WebSocketSurface { + operation: "AUTH", + protection: SurfaceProtection::Session { capability: None }, + guard_points: REQUEST, + }, + WebSocketSurface { + operation: "REQ", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_STREAM, + }, + WebSocketSurface { + operation: "COUNT", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_EMIT, + }, + WebSocketSurface { + operation: "EVENT", + protection: SurfaceProtection::DynamicAtomicMutation, + guard_points: REQUEST_COMMIT, + }, + WebSocketSurface { + operation: "fanout", + protection: SurfaceProtection::Capability(AuthorizationCapability::CommunityRead), + guard_points: REQUEST_STREAM, + }, +]; + +/// Return the exact capability for a WebSocket operation. +/// +/// AUTH deliberately returns `None`: verification or authentication alone must +/// never become data-plane authority. Every subsequent operation requests its +/// own capability through the same direct/delegated runtime path. +pub const fn websocket_capability( + operation: WebSocketOperation, +) -> Option { + match operation { + WebSocketOperation::Auth => None, + WebSocketOperation::Req | WebSocketOperation::Count | WebSocketOperation::Fanout => { + Some(AuthorizationCapability::CommunityRead) + } + WebSocketOperation::Event { kind: 9040..=9044 } => Some(AuthorizationCapability::Moderate), + WebSocketOperation::Event { .. } => Some(AuthorizationCapability::CommunityWrite), + } +} + +/// Return the exact HTTP bridge event-ingest capability. +pub const fn event_ingest_capability(kind: u32) -> AuthorizationCapability { + match kind { + 9040..=9044 => AuthorizationCapability::Moderate, + _ => AuthorizationCapability::CommunityWrite, + } +} + +/// Resolve Git's `info/refs` capability from the server-validated service. +pub fn git_info_refs_capability(service: &str) -> Option { + match service { + "git-upload-pack" => Some(AuthorizationCapability::GitRead), + "git-receive-pack" => Some(AuthorizationCapability::GitWrite), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn effect_inventory_is_closed_unique_and_guarded() { + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + for surface in EFFECT_SURFACES { + assert!( + ids.insert(surface.id), + "duplicate effect id: {:?}", + surface.id + ); + assert!( + names.insert(surface.name), + "duplicate effect name: {}", + surface.name + ); + assert!(!surface.name.is_empty()); + assert!(!surface.guard_points.is_empty()); + if surface.class == EffectClass::AuthoritativeMutation + && surface.enforce == EnforceDisposition::Supported + && surface.id != EffectSurfaceId::AudioAdmission + { + assert!(surface.guard_points.contains(&GuardPoint::PreCommit)); + } + if surface.class == EffectClass::DerivedDelivery + && surface.enforce == EnforceDisposition::Supported + { + assert!(surface.guard_points.contains(&GuardPoint::PreEmission)); + } + assert_eq!(effect_surface_by_name(surface.name), Some(surface)); + } + } + + #[test] + fn event_effect_classification_separates_transactional_and_unavailable_paths() { + for kind in [ + buzz_core::kind::KIND_TEXT_NOTE, + buzz_core::kind::KIND_REACTION, + buzz_core::kind::KIND_DELETION, + buzz_core::kind::KIND_REPORT, + buzz_core::kind::KIND_LONG_FORM, + buzz_core::kind::KIND_MODERATION_BAN, + buzz_core::kind::KIND_GIT_REPO_ANNOUNCEMENT, + buzz_core::kind::KIND_PROFILE, + buzz_core::kind::KIND_AGENT_PROFILE, + buzz_core::kind::KIND_PRODUCT_FEEDBACK, + buzz_core::kind::KIND_NIP43_LEAVE_REQUEST, + buzz_core::kind::KIND_IA_ARCHIVE_REQUEST, + buzz_core::kind::KIND_IA_UNARCHIVE_REQUEST, + buzz_core::kind::RELAY_ADMIN_ADD_MEMBER, + buzz_core::kind::RELAY_ADMIN_REMOVE_MEMBER, + buzz_core::kind::RELAY_ADMIN_CHANGE_ROLE, + buzz_core::kind::RELAY_ADMIN_SET_WORKSPACE_PROFILE, + ] { + assert_eq!( + event_mutation_disposition(kind), + EventMutationDisposition::TransactionalPersistence, + "interactive kind {kind} must retain transaction-owned persistence" + ); + } + for kind in [ + buzz_core::kind::KIND_NIP29_PUT_USER, + buzz_core::kind::KIND_NIP29_REMOVE_USER, + buzz_core::kind::KIND_NIP29_EDIT_METADATA, + buzz_core::kind::KIND_NIP29_DELETE_EVENT, + buzz_core::kind::KIND_NIP29_CREATE_GROUP, + buzz_core::kind::KIND_NIP29_DELETE_GROUP, + buzz_core::kind::KIND_NIP29_JOIN_REQUEST, + buzz_core::kind::KIND_NIP29_LEAVE_REQUEST, + buzz_core::kind::KIND_NIP29_CREATE_INVITE, + ] { + assert_eq!( + event_mutation_disposition(kind), + EventMutationDisposition::TransactionalPersistence + ); + } + assert_eq!( + event_mutation_disposition(buzz_core::kind::KIND_WORKFLOW_TRIGGER), + EventMutationDisposition::UnavailableCommandOrWorkflow + ); + } + + #[test] + fn enforce_denies_background_webhooks_and_automatic_membership_before_effect() { + for id in [ + EffectSurfaceId::WorkflowBackgroundExecution, + EffectSurfaceId::OutboundWebhook, + EffectSurfaceId::UnclassifiedHelper, + EffectSurfaceId::AudioAutomaticMembership, + EffectSurfaceId::ReminderWorker, + EffectSurfaceId::PushWorker, + EffectSurfaceId::LegacyAuditDelivery, + ] { + assert!(require_effect_permit(Some(AuthorizationMode::Enforce), id).is_err()); + assert!(require_effect_permit(Some(AuthorizationMode::Off), id).is_ok()); + assert!(require_effect_permit(Some(AuthorizationMode::Shadow), id).is_ok()); + assert!(require_effect_permit(Some(AuthorizationMode::VerifyOnly), id).is_ok()); + assert!(require_effect_permit(None, id).is_ok()); + } + } + + #[test] + fn unknown_helper_name_has_no_enforce_fallback() { + assert!(effect_surface_by_name("helper.added_without_classification").is_none()); + assert!(require_effect_name( + Some(AuthorizationMode::Enforce), + "helper.added_without_classification" + ) + .is_err()); + assert!(require_effect_name( + Some(AuthorizationMode::Off), + "helper.added_without_classification" + ) + .is_ok()); + } + + fn assert_ordered(source: &str, first: &str, second: &str) { + let first_index = source.find(first).expect("first boundary exists"); + let second_index = source.find(second).expect("second boundary exists"); + assert!(first_index < second_index, "{first} must precede {second}"); + } + + #[test] + fn inventory_keys_are_unique_and_every_row_has_guards() { + let mut seen = HashSet::new(); + for surface in HTTP_SURFACES { + assert!( + seen.insert((surface.method, surface.matched_path)), + "duplicate protected-surface row for {} {}", + surface.method, + surface.matched_path + ); + assert!(!surface.guard_points.is_empty()); + } + for surface in WEBSOCKET_SURFACES { + assert!(seen.insert(("WS", surface.operation))); + assert!(!surface.guard_points.is_empty()); + } + } + + #[test] + fn dynamic_operations_request_exact_capabilities() { + assert_eq!(websocket_capability(WebSocketOperation::Auth), None); + assert_eq!( + websocket_capability(WebSocketOperation::Req), + Some(AuthorizationCapability::CommunityRead) + ); + assert_eq!( + event_ingest_capability(9042), + AuthorizationCapability::Moderate + ); + assert_eq!( + event_ingest_capability(1), + AuthorizationCapability::CommunityWrite + ); + assert!(protected_operation_matches( + "invite.claim", + AuthTransport::HttpBridge, + AuthorizationCapability::InviteClaim, + )); + assert!(!protected_operation_matches( + "invite.claim", + AuthTransport::HttpBridge, + AuthorizationCapability::InviteMint, + )); + } + + #[test] + fn conditional_and_session_roots_are_explicit() { + assert!(matches!( + classify_http(&Method::GET, "/").map(|surface| surface.protection), + Some(SurfaceProtection::ConditionalWebSocketUpgrade) + )); + assert!(matches!( + classify_http(&Method::HEAD, "/").map(|surface| surface.protection), + Some(SurfaceProtection::Exempt(SurfaceExemption::PublicMetadata)) + )); + assert!(matches!( + classify_http(&Method::GET, "/huddle/{channel_id}/audio") + .map(|surface| surface.protection), + Some(SurfaceProtection::LeasedSession { + capability: AuthorizationCapability::AudioJoin + }) + )); + assert!(matches!( + classify_http(&Method::HEAD, "/media/{sha256_ext}").map(|surface| surface.protection), + Some(SurfaceProtection::ConditionalMediaRead( + AuthorizationCapability::MediaRead + )) + )); + } + + #[test] + fn exemptions_are_named_and_unknown_routes_fail_classification() { + assert!(matches!( + classify_http(&Method::GET, "/_readiness").map(|surface| surface.protection), + Some(SurfaceProtection::Exempt(SurfaceExemption::HealthProbe)) + )); + assert!(classify_http(&Method::GET, "/unclassified").is_none()); + assert!(classify_http(&Method::GET, "/media/literal-sha").is_none()); + assert_eq!(NON_ROUTER_SURFACES.len(), 1); + assert!(matches!( + NON_ROUTER_SURFACES[0].protection, + SurfaceProtection::Exempt(SurfaceExemption::StaticUiFallback) + )); + } + + #[test] + fn git_advertisement_service_selects_read_or_write() { + assert_eq!( + git_info_refs_capability("git-upload-pack"), + Some(AuthorizationCapability::GitRead) + ); + assert_eq!( + git_info_refs_capability("git-receive-pack"), + Some(AuthorizationCapability::GitWrite) + ); + assert_eq!(git_info_refs_capability("unknown"), None); + } + + #[test] + fn every_mutating_route_declares_its_authoritative_or_unavailable_boundary() { + for (method, path) in [ + (Method::POST, "/events"), + (Method::POST, "/api/invites"), + (Method::POST, "/api/invites/claim"), + ] { + let protection = classify_http(&method, path) + .expect("mutating route is inventoried") + .protection; + assert!(matches!( + protection, + SurfaceProtection::AtomicMutation(_) | SurfaceProtection::DynamicAtomicMutation + )); + } + assert!(matches!( + classify_http(&Method::POST, "/hooks/{id}") + .expect("webhook is inventoried") + .protection, + SurfaceProtection::AtomicMutationUnavailable(_) + )); + for (method, path) in [ + (Method::PUT, "/upload"), + (Method::PUT, "/media/upload"), + (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + ] { + assert!(matches!( + classify_http(&method, path) + .expect("functional mutation is inventoried") + .protection, + SurfaceProtection::AtomicMutation(_) + )); + } + assert!(matches!( + classify_http(&Method::GET, "/huddle/{channel_id}/audio") + .expect("audio is inventoried") + .protection, + SurfaceProtection::LeasedSession { .. } + )); + assert!(WEBSOCKET_SURFACES.iter().any(|surface| { + surface.operation == "EVENT" + && surface.protection == SurfaceProtection::DynamicAtomicMutation + && surface.guard_points.contains(&GuardPoint::PreCommit) + })); + } + + #[test] + fn enforce_mutation_gates_precede_every_shared_business_effect_boundary() { + let ingest = include_str!("handlers/ingest.rs"); + assert_ordered( + ingest, + "event_mutation_disposition", + "handle_moderation_command", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "insert_event_with_thread_metadata_tx", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "apply_nip29_mutation_tx", + ); + let report_path = ingest + .split_once("if kind_u32 == KIND_REPORT") + .expect("report path exists") + .1; + assert_ordered( + report_path, + "handle_report_event_enforced", + "return Ok(IngestResult", + ); + let moderation_path = ingest + .split_once("if buzz_core::kind::is_moderation_command_kind(kind_u32)") + .expect("moderation path exists") + .1; + assert_ordered( + moderation_path, + "handle_moderation_command_enforced", + "return Ok(IngestResult", + ); + let feedback_path = ingest + .split_once("if kind_u32 == KIND_PRODUCT_FEEDBACK") + .expect("feedback path exists") + .1; + assert_ordered( + feedback_path, + "handle_enforced(tenant, &event, state, &protected)", + "emit_product_feedback_success", + ); + let relay_admin = include_str!("handlers/relay_admin.rs"); + let enforced_relay_admin = relay_admin + .split_once("handle_relay_admin_event_enforced") + .expect("protected relay-admin executor exists") + .1; + assert_ordered( + enforced_relay_admin, + "begin_authorized_operation", + "execute_relay_admin_command_tx", + ); + let identity_archive = include_str!("handlers/identity_archive.rs"); + let enforced_archive = identity_archive + .split_once("handle_identity_archive_event_tx") + .expect("protected archive transaction exists") + .1; + assert_ordered(enforced_archive, "determine_consent_path_tx", "archive_tx"); + let relay_leave = ingest + .split_once("if kind_u32 == KIND_NIP43_LEAVE_REQUEST") + .expect("relay leave path exists") + .1; + assert_ordered( + relay_leave, + "begin_authorized_operation", + "remove_relay_member_tx", + ); + assert_ordered( + ingest, + "begin_authorized_operation", + "replace_protected_announcement_tx", + ); + let media = include_str!("api/media.rs"); + assert_ordered( + media, + "fn acquire_protected_upload_permit(", + "if upload_rate_limited(", + ); + assert_ordered( + media, + "fn acquire_protected_upload_permit(", + "acquire_upload_permit(state, community_id, pubkey)", + ); + let media_upload = media + .split_once("pub async fn upload_blob") + .expect("media upload handler exists") + .1; + assert_ordered( + media_upload, + "commit_media_publication(", + "Ok(Json(descriptor))", + ); + + let git = include_str!("api/git/transport.rs"); + let finalize_push = git + .split_once("async fn finalize_push") + .expect("Git push finalizer exists") + .1; + assert_ordered( + finalize_push, + "begin_authorized_operation(", + "compare_and_publish_git(", + ); + let after_commit = finalize_push + .split_once("operation.commit(&payload)") + .expect("PostgreSQL Git publication commits a receipt") + .1; + assert!(after_commit.contains("build_git_response(\"receive-pack\"")); + + let bridge = include_str!("api/bridge.rs"); + let webhook = bridge + .split_once("pub async fn workflow_webhook") + .expect("workflow webhook handler exists") + .1; + assert_ordered(webhook, "require_effect_permit(", ".get_workflow("); + assert_ordered(webhook, "require_effect_permit(", ".create_workflow_run("); + assert_ordered( + include_str!("workflow_sink.rs"), + "require_effect_permit(", + ".insert_event_with_thread_metadata(", + ); + + let workflow_engine = include_str!("../../buzz-workflow/src/lib.rs"); + let on_event = workflow_engine + .split_once("pub async fn on_event") + .expect("event workflow trigger exists") + .1; + assert_ordered(on_event, "self.require_mutation(", ".create_workflow_run("); + let scheduler = workflow_engine + .split_once("pub async fn run") + .expect("workflow scheduler exists") + .1; + assert_ordered( + scheduler, + "self.require_mutation(", + ".claim_scheduled_workflow_fire(", + ); + assert_ordered(scheduler, "self.require_mutation(", ".create_workflow_run("); + + let workflow_executor = include_str!("../../buzz-workflow/src/executor.rs"); + let action_dispatch = workflow_executor + .split_once("pub async fn dispatch_action") + .expect("workflow action dispatcher exists") + .1; + assert_ordered( + action_dispatch, + "engine.require_mutation(", + "add_reaction_impl(", + ); + assert_ordered( + action_dispatch, + "engine.require_outbound_webhook(", + "call_webhook_impl(", + ); + + let relay_main = include_str!("main.rs"); + assert_ordered(relay_main, "set_mutation_gate(", "wf_cron.run("); + assert_ordered( + relay_main, + "enforcing_protected_domain_ids()", + "reap_expired_ephemeral_channels_excluding", + ); + assert_ordered( + relay_main, + "enforcing_protected_domain_ids()", + "query_due_reminders_excluding", + ); + + let push = include_str!("push_runtime.rs"); + assert_ordered( + push, + "enforcing_protected_domain_ids()", + "claim_due_push_match_batch_excluding", + ); + assert_ordered(push, "is_protected_enforcing", "claim_due_push_wakes"); + + let websocket = include_str!("handlers/event.rs"); + let ephemeral = websocket + .split_once("async fn handle_ephemeral_event") + .expect("ephemeral event handler exists") + .1; + assert_ordered(ephemeral, "authority.revalidate()", ".publish_event("); + assert_ordered( + ephemeral, + "authority.revalidate()", + "fan_out_event_to_local_subscribers", + ); + assert!(ephemeral.contains("fan_out_event_to_local_subscribers_with_authority")); + let observer = websocket + .split_once("async fn handle_agent_observer_event") + .expect("observer event handler exists") + .1; + assert_ordered(observer, "authority.revalidate()", ".publish_event("); + assert_ordered( + observer, + "authority.revalidate()", + "fan_out_event_to_local_subscribers", + ); + assert!(observer.contains("fan_out_event_to_local_subscribers_with_authority")); + let presence = websocket + .split_once("if event_kind_u32(&event) == KIND_PRESENCE_UPDATE") + .expect("presence effect boundary exists") + .1; + assert_ordered(presence, "encode_presence(", ".set_presence("); + assert!(websocket.contains("send_to_text_bytes_guarded_pair")); + assert_ordered( + websocket, + "if legacy_audit_delivery_allowed", + "enqueue_event_created_audit(", + ); + let connection = include_str!("connection.rs"); + assert!(connection.contains("CombinedReleaseFence")); + assert!(connection.contains("self.sender.release().await")); + assert!(connection.contains("self.recipient.release().await")); + + let presence_read = bridge + .split_once("async fn synthesize_presence") + .expect("presence read boundary exists") + .1; + assert_ordered(presence_read, "decode_presence(", "verify_actor_context("); + assert_ordered( + presence_read, + "verify_actor_context(", + "presence_map.insert(", + ); + + let invites = include_str!("api/invites.rs"); + let mint = invites + .split_once("pub async fn mint_invite") + .expect("invite mint handler exists") + .1; + assert_ordered(mint, "begin_authorized_operation", "mint_relay_invite_tx"); + let claim = invites + .split_once("pub async fn claim_invite") + .expect("invite claim handler exists") + .1; + assert_ordered( + claim, + "begin_authorized_enrollment", + "claim_relay_invite_with_identity_tx", + ); + + assert_ordered( + include_str!("audio/handler.rs"), + "debug_assert!(!protected_authority.is_enforcing())", + ".add_member_with_identity(", + ); + + let corporate_identity = include_str!("corporate_identity.rs") + .split_once("async fn record_identity_binding_audit") + .expect("identity audit helper exists") + .1; + assert_ordered( + corporate_identity, + "require_effect_permit(", + "let Some(audit_tx)", + ); + + let media_audit = media + .split_once("// Audit via bounded channel") + .expect("media audit boundary exists") + .1; + assert_ordered(media_audit, "require_effect_permit(", "audit_tx"); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 7737604495..5c922b2e52 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -206,16 +206,20 @@ async fn enforce_corporate_identity_route_inventory( request: Request, next: Next, ) -> axum::response::Response { - enforce_route_inventory_for_requirement(state.config.corporate_identity.require, request, next) - .await + enforce_route_inventory_for_requirement( + state.config.corporate_identity.require || state.protected_transport().is_some(), + request, + next, + ) + .await } async fn enforce_route_inventory_for_requirement( - corporate_identity_required: bool, + protected_inventory_required: bool, request: Request, next: Next, ) -> axum::response::Response { - if !corporate_identity_required { + if !protected_inventory_required { return next.run(request).await; } let matched_path = request.extensions().get::(); @@ -240,11 +244,11 @@ async fn enforce_route_inventory_for_requirement( tracing::error!( method = %request.method(), matched_path = matched_path.map(|path| path.as_str()).unwrap_or(""), - "rejecting route missing corporate identity policy classification" + "rejecting route missing protected-surface policy classification" ); ( StatusCode::SERVICE_UNAVAILABLE, - "route unavailable: identity policy is not configured", + "route unavailable: protected-surface policy is not configured", ) .into_response() } @@ -374,10 +378,21 @@ async fn nip11_or_ws_handler( .into_response(); } }; - let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( - &headers, - &state.config.corporate_identity, - ); + let corporate_identity_assertion = + match crate::corporate_identity::identity_assertion_from_headers( + &state, + tenant.community(), + &headers, + ) { + Ok(assertion) => assertion, + Err(error) => { + return ( + error.status_code(), + format!("restricted: {}", error.public_message()), + ) + .into_response() + } + }; let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { @@ -393,7 +408,7 @@ async fn nip11_or_ws_handler( } limit_relay_websocket(ws, max_frame_bytes) .on_upgrade(move |socket| { - handle_connection(socket, state, addr, tenant, corporate_identity_jwt) + handle_connection(socket, state, addr, tenant, corporate_identity_assertion) }) .into_response() } diff --git a/crates/buzz-relay/src/router/route_policy.rs b/crates/buzz-relay/src/router/route_policy.rs index 38859e10a9..37653953da 100644 --- a/crates/buzz-relay/src/router/route_policy.rs +++ b/crates/buzz-relay/src/router/route_policy.rs @@ -1,369 +1,33 @@ -//! Central inventory of corporate-identity policy at the HTTP routing boundary. -//! -//! This module classifies axum's *matched route template* (for example, -//! `/media/{sha256_ext}`), not an untrusted literal request path. Keeping the -//! complete inventory here makes every authenticated surface and every -//! deliberate exemption reviewable in one place. The handlers remain the -//! enforcement point because they have the authenticated principal, resolved -//! tenant, and admission result needed to finalize an identity safely. +//! Router adapter for the provider-neutral protected-surface inventory. use axum::http::Method; -/// Why a route deliberately does not use tenant corporate-identity auth. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CorporateIdentityExemption { - /// Public relay metadata (NIP-05, NIP-11-adjacent information). - PublicMetadata, - /// Kubernetes/service health endpoint. - HealthProbe, - /// Public pre-membership policy and policy-acceptance bootstrap. - JoinBootstrap, - /// Deployment-global operator NIP-98 allowlist, outside tenant auth. - OperatorAuth, - /// Deployment-admin host/session authentication, outside tenant auth. - AdminAuth, - /// Per-workflow secret authentication. - WebhookSecret, - /// Loopback-only, HMAC-authenticated Git hook callback. - LocalHookCallback, - /// Disabled-by-default mesh testbed endpoint. - TestbedOnly, -} - -/// Corporate-identity policy for a registered route. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CorporateIdentityRoutePolicy { - /// Authenticate and enforce corporate identity during this HTTP request. - Required, - /// Enforce when the upgraded WebSocket performs its protocol auth flow. - RequiredAtSessionAuth, - /// Public only when protected media reads are disabled; otherwise required. - RequiredWhenMediaReadsProtected, - /// Deliberately outside tenant corporate-identity authentication. - Exempt(CorporateIdentityExemption), -} - -impl CorporateIdentityRoutePolicy { - /// Stable, low-cardinality label used on HTTP trace spans. - pub(super) const fn trace_label(self) -> &'static str { - match self { - Self::Required => "required", - Self::RequiredAtSessionAuth => "required_at_session_auth", - Self::RequiredWhenMediaReadsProtected => "required_when_media_reads_protected", - Self::Exempt(CorporateIdentityExemption::PublicMetadata) => "exempt_public_metadata", - Self::Exempt(CorporateIdentityExemption::HealthProbe) => "exempt_health_probe", - Self::Exempt(CorporateIdentityExemption::JoinBootstrap) => "exempt_join_bootstrap", - Self::Exempt(CorporateIdentityExemption::OperatorAuth) => "exempt_operator_auth", - Self::Exempt(CorporateIdentityExemption::AdminAuth) => "exempt_admin_auth", - Self::Exempt(CorporateIdentityExemption::WebhookSecret) => "exempt_webhook_secret", - Self::Exempt(CorporateIdentityExemption::LocalHookCallback) => { - "exempt_local_hook_callback" - } - Self::Exempt(CorporateIdentityExemption::TestbedOnly) => "exempt_testbed_only", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct RoutePolicyRule { - method: &'static str, - matched_path: &'static str, - policy: CorporateIdentityRoutePolicy, -} - -const REQUIRED: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::Required; -const SESSION: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::RequiredAtSessionAuth; -const PROTECTED_MEDIA: CorporateIdentityRoutePolicy = - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected; - -const fn exempt(exemption: CorporateIdentityExemption) -> CorporateIdentityRoutePolicy { - CorporateIdentityRoutePolicy::Exempt(exemption) -} - -/// Exhaustive inventory of registered relay routes. -/// -/// Static UI fallback paths are intentionally absent: they do not have an -/// axum `MatchedPath` and cannot reach an API handler. A missing API entry is -/// visible as `unclassified` in the HTTP trace span and must be added here as -/// part of registering the route. -const ROUTE_POLICY_RULES: &[RoutePolicyRule] = &[ - // Protocol and public metadata. - RoutePolicyRule { - method: "GET", - matched_path: "/", - policy: SESSION, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/info", - policy: exempt(CorporateIdentityExemption::PublicMetadata), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/.well-known/nostr.json", - policy: exempt(CorporateIdentityExemption::PublicMetadata), - }, - // Health routes on the primary and health-only listeners. - RoutePolicyRule { - method: "GET", - matched_path: "/health", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_liveness", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_readiness", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_status", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/_mesh", - policy: exempt(CorporateIdentityExemption::HealthProbe), - }, - // NIP-98 HTTP bridge. - RoutePolicyRule { - method: "POST", - matched_path: "/events", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/query", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/count", - policy: REQUIRED, - }, - // Deployment-global operator control plane. - RoutePolicyRule { - method: "GET", - matched_path: "/operator/communities", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/archive", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/unarchive", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/operator/communities/availability", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/operator/communities/transfer", - policy: exempt(CorporateIdentityExemption::OperatorAuth), - }, - // Invite admission and its deliberately public pre-join policy surface. - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites/claim", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy/terms", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/join-policy/privacy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/api/invites/accept-policy", - policy: exempt(CorporateIdentityExemption::JoinBootstrap), - }, - // Moderation data is tenant-authenticated even though it is not event data. - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/reports", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/audit", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/moderation/restricted", - policy: REQUIRED, - }, - // Alternate-auth and test-only callbacks. - RoutePolicyRule { - method: "POST", - matched_path: "/hooks/{id}", - policy: exempt(CorporateIdentityExemption::WebhookSecret), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/_mesh/demo/echo", - policy: exempt(CorporateIdentityExemption::TestbedOnly), - }, - RoutePolicyRule { - method: "POST", - matched_path: "/internal/git/policy", - policy: exempt(CorporateIdentityExemption::LocalHookCallback), - }, - // Huddle authentication is performed inside the upgraded socket. - RoutePolicyRule { - method: "GET", - matched_path: "/huddle/{channel_id}/audio", - policy: SESSION, - }, - // Blossom media: writes are always authenticated; reads are configurable. - RoutePolicyRule { - method: "PUT", - matched_path: "/upload", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "PUT", - matched_path: "/media/upload", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "GET", - matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, - }, - RoutePolicyRule { - method: "HEAD", - matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, - }, - // Git smart HTTP is tenant-authenticated on every request. - RoutePolicyRule { - method: "GET", - matched_path: "/git/{owner}/{repo}/info/refs", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/git/{owner}/{repo}/git-upload-pack", - policy: REQUIRED, - }, - RoutePolicyRule { - method: "POST", - matched_path: "/git/{owner}/{repo}/git-receive-pack", - policy: REQUIRED, - }, - // Deployment-admin APIs use the dedicated admin-host auth middleware. - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/reports", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/reports/{id}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback/{id}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, - RoutePolicyRule { - method: "GET", - matched_path: "/api/admin/v1/feedback/{id}/attachments/{sha256}", - policy: exempt(CorporateIdentityExemption::AdminAuth), - }, -]; +#[cfg(test)] +use crate::protected_surface::SurfaceExemption as CorporateIdentityExemption; +pub(super) use crate::protected_surface::SurfaceProtection as CorporateIdentityRoutePolicy; -/// Classify a registered method and axum matched-path template. +/// Classify a registered method and Axum matched-path template. pub(super) fn classify_matched_route( method: &Method, matched_path: &str, ) -> Option { - let exact = ROUTE_POLICY_RULES - .iter() - .find(|rule| rule.method == method.as_str() && rule.matched_path == matched_path) - .map(|rule| rule.policy); - if exact.is_some() || method != Method::HEAD { - return exact; - } - - // axum automatically serves HEAD through GET routes when no explicit HEAD - // handler is registered. Mirror that routing fallback so those requests - // cannot appear unclassified. The explicit protected-media HEAD rule above - // wins before this branch. - ROUTE_POLICY_RULES - .iter() - .find(|rule| rule.method == "GET" && rule.matched_path == matched_path) - .map(|rule| rule.policy) + crate::protected_surface::classify_http(method, matched_path).map(|entry| entry.protection) } -/// Whether this matched template is registered in the inventory for any -/// method. An unknown method on a known template is a 405, not a new route. +/// Whether this matched template is registered for any method. pub(super) fn is_known_matched_path(matched_path: &str) -> bool { - ROUTE_POLICY_RULES - .iter() - .any(|rule| rule.matched_path == matched_path) + crate::protected_surface::is_known_http_path(matched_path) } -/// RFC 9110 `Allow` value for a known matched template. GET routes include -/// Axum's implicit HEAD support. +/// RFC 9110 `Allow` value for a known matched template. pub(super) fn allowed_methods(matched_path: &str) -> Option { - let mut methods = Vec::new(); - for rule in ROUTE_POLICY_RULES - .iter() - .filter(|rule| rule.matched_path == matched_path) - { - if !methods.contains(&rule.method) { - methods.push(rule.method); - } - if rule.method == "GET" && !methods.contains(&"HEAD") { - methods.push("HEAD"); - } - } - (!methods.is_empty()).then(|| methods.join(", ")) + crate::protected_surface::allowed_http_methods(matched_path) } #[cfg(test)] mod tests { - use std::collections::HashSet; - use super::*; + use buzz_auth::AuthorizationCapability; fn policy(method: Method, path: &str) -> CorporateIdentityRoutePolicy { classify_matched_route(&method, path) @@ -371,129 +35,111 @@ mod tests { } #[test] - fn every_policy_rule_has_a_unique_method_and_path() { - let mut seen = HashSet::new(); - for rule in ROUTE_POLICY_RULES { - assert!( - seen.insert((rule.method, rule.matched_path)), - "duplicate route policy for {} {}", - rule.method, - rule.matched_path - ); - } - } - - #[test] - fn every_tenant_authenticated_http_route_requires_corporate_identity() { - let routes = [ - (Method::POST, "/events"), - (Method::POST, "/query"), - (Method::POST, "/count"), - (Method::POST, "/api/invites"), - (Method::POST, "/api/invites/claim"), - (Method::GET, "/moderation/reports"), - (Method::GET, "/moderation/audit"), - (Method::GET, "/moderation/restricted"), - (Method::PUT, "/upload"), - (Method::PUT, "/media/upload"), - (Method::GET, "/git/{owner}/{repo}/info/refs"), - (Method::POST, "/git/{owner}/{repo}/git-upload-pack"), - (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + fn tenant_routes_map_to_exact_portable_capabilities() { + let read_routes = [ + ( + Method::POST, + "/query", + AuthorizationCapability::CommunityRead, + ), + ( + Method::GET, + "/moderation/reports", + AuthorizationCapability::Moderate, + ), ]; - for (method, path) in routes { - assert_eq!(policy(method, path), CorporateIdentityRoutePolicy::Required); + for (method, path, capability) in read_routes { + assert_eq!( + policy(method, path), + CorporateIdentityRoutePolicy::Capability(capability) + ); } - } - - #[test] - fn websocket_and_media_policies_capture_deferred_and_conditional_auth() { - assert_eq!( - policy(Method::GET, "/"), - CorporateIdentityRoutePolicy::RequiredAtSessionAuth - ); - assert_eq!( - policy(Method::GET, "/huddle/{channel_id}/audio"), - CorporateIdentityRoutePolicy::RequiredAtSessionAuth - ); - for method in [Method::GET, Method::HEAD] { + let unavailable_mutation_routes = [( + Method::POST, + "/hooks/{id}", + AuthorizationCapability::CommunityWrite, + )]; + for (method, path, capability) in unavailable_mutation_routes { assert_eq!( - policy(method, "/media/{sha256_ext}"), - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected + policy(method, path), + CorporateIdentityRoutePolicy::AtomicMutationUnavailable(capability) ); } - } - - #[test] - fn privileged_non_tenant_surfaces_have_narrow_named_exemptions() { - let routes = [ + for (method, path, capability) in [ ( Method::POST, - "/operator/communities/archive", - CorporateIdentityExemption::OperatorAuth, - ), - ( - Method::GET, - "/api/admin/v1/reports", - CorporateIdentityExemption::AdminAuth, + "/api/invites", + AuthorizationCapability::InviteMint, ), ( Method::POST, - "/hooks/{id}", - CorporateIdentityExemption::WebhookSecret, + "/api/invites/claim", + AuthorizationCapability::InviteClaim, ), + (Method::PUT, "/upload", AuthorizationCapability::MediaWrite), ( Method::POST, - "/internal/git/policy", - CorporateIdentityExemption::LocalHookCallback, + "/git/{owner}/{repo}/git-receive-pack", + AuthorizationCapability::GitWrite, ), - ]; - for (method, path, exemption) in routes { + ] { assert_eq!( policy(method, path), - CorporateIdentityRoutePolicy::Exempt(exemption) + CorporateIdentityRoutePolicy::AtomicMutation(capability) ); } + assert_eq!( + policy(Method::POST, "/events"), + CorporateIdentityRoutePolicy::DynamicAtomicMutation + ); + assert_eq!( + policy(Method::GET, "/git/{owner}/{repo}/info/refs"), + CorporateIdentityRoutePolicy::DynamicCapability + ); } #[test] - fn public_routes_are_explicit_and_unknown_routes_are_unclassified() { + fn websocket_and_media_policies_are_deferred_or_conditional() { + assert_eq!( + policy(Method::GET, "/"), + CorporateIdentityRoutePolicy::ConditionalWebSocketUpgrade + ); assert_eq!( - policy(Method::GET, "/.well-known/nostr.json"), + policy(Method::HEAD, "/"), CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) ); assert_eq!( - policy(Method::GET, "/_readiness"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) + policy(Method::GET, "/huddle/{channel_id}/audio"), + CorporateIdentityRoutePolicy::LeasedSession { + capability: AuthorizationCapability::AudioJoin + } ); + for method in [Method::GET, Method::HEAD] { + assert_eq!( + policy(method, "/media/{sha256_ext}"), + CorporateIdentityRoutePolicy::ConditionalMediaRead( + AuthorizationCapability::MediaRead + ) + ); + } + } + + #[test] + fn exemptions_are_narrow_and_unknown_routes_are_unclassified() { assert_eq!( - policy(Method::GET, "/api/join-policy"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::JoinBootstrap) + policy(Method::GET, "/_readiness"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) ); assert_eq!( - policy(Method::POST, "/_mesh/demo/echo"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::TestbedOnly) + policy(Method::POST, "/internal/git/policy"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::LocalHookCallback) ); assert_eq!( policy(Method::HEAD, "/info"), - CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata), - "axum's automatic GET-to-HEAD fallback inherits the GET policy" + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) ); - assert_eq!(classify_matched_route(&Method::GET, "/events"), None); assert_eq!(classify_matched_route(&Method::GET, "/unknown"), None); - assert_eq!( - classify_matched_route(&Method::GET, "/media/literal-sha"), - None, - "the classifier accepts trusted matched templates, not literal paths" - ); - } - - #[test] - fn known_path_detection_distinguishes_method_fallbacks_from_new_routes() { assert!(is_known_matched_path("/events")); - assert!(is_known_matched_path("/health")); - assert!(!is_known_matched_path("/new-unclassified-route")); assert_eq!(allowed_methods("/events").as_deref(), Some("POST")); - assert_eq!(allowed_methods("/info").as_deref(), Some("GET, HEAD")); - assert_eq!(allowed_methods("/new-unclassified-route"), None); } } diff --git a/crates/buzz-relay/src/storage_sweep.rs b/crates/buzz-relay/src/storage_sweep.rs index eccadcd835..241748d970 100644 --- a/crates/buzz-relay/src/storage_sweep.rs +++ b/crates/buzz-relay/src/storage_sweep.rs @@ -25,6 +25,7 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use uuid::Uuid; +use buzz_core::CommunityId; use buzz_media::{BucketSnapshot, SweepError}; /// Sweep knobs, read once at boot. See `PLANS/S3_STORAGE_METRICS_PLAN.md` F7. @@ -107,9 +108,9 @@ struct SweepAttempt { /// renamed, or scope-excluded) are zeroed rather than left at their last /// nonzero value until the recorder's idle-eviction kicks in. /// -/// Carries the resolved host label (not the UUID) so a rename can still zero -/// the old series, and distinguishes bytes vs. objects because they are -/// separate Prometheus series. +/// Carries a stable runtime pseudonym rather than a host or UUID, and +/// distinguishes bytes vs. objects because they are separate Prometheus +/// series. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) enum StorageEmittedKey { Bytes(String), @@ -119,12 +120,12 @@ pub(crate) enum StorageEmittedKey { impl StorageEmittedKey { fn set(&self, value: f64) { match self { - Self::Bytes(host) => { - metrics::gauge!("buzz_community_storage_bytes", "community" => host.clone()) + Self::Bytes(label) => { + metrics::gauge!("buzz_community_storage_bytes", "community" => label.clone()) .set(value); } - Self::Objects(host) => { - metrics::gauge!("buzz_community_storage_objects", "community" => host.clone()) + Self::Objects(label) => { + metrics::gauge!("buzz_community_storage_objects", "community" => label.clone()) .set(value); } } @@ -263,15 +264,15 @@ pub async fn maybe_spawn_sweep( /// never from the spawned sweep task itself, so a sweep that completes after /// this pod loses leadership parks its snapshot without ever publishing it. /// -/// `host_map` resolves a community UUID to its label string for per- -/// community series; `allows` gates those series the same way +/// `host_map` proves that a community UUID still resolves to a live tenant; +/// the emitted label is an opaque runtime pseudonym. `allows` gates those series the same way /// `EmissionScope` gates the DB-derived ones. A bound community UUID absent /// from `host_map` is "unmapped" (sidecar references a community with no DB /// row) and rolls into `buzz_storage_unmapped_community_bytes` instead of a /// per-community series. /// /// Per-community series whose community disappears from the current snapshot -/// (unmapped, host rename, or scope exclusion) are explicitly zeroed — the +/// (unmapped or scope exclusion) are explicitly zeroed — the /// same pattern as `emit_in_memory_usage_metrics`. Without this, a series /// would linger at its last nonzero value until the recorder's idle eviction /// fires (≥3 ticks), producing a transient double-count against the @@ -326,19 +327,20 @@ pub async fn emit_storage_metrics( let mut current = HashSet::new(); let mut unmapped_bytes = 0u64; for (community_id, storage) in &snapshot.per_community { - let Some(host) = host_map.get(community_id) else { + if !host_map.contains_key(community_id) { unmapped_bytes += storage.bytes; continue; - }; + } if !allows(community_id) { continue; } - metrics::gauge!("buzz_community_storage_bytes", "community" => host.clone()) + let label = crate::metrics::community_label(CommunityId::from_uuid(*community_id)); + metrics::gauge!("buzz_community_storage_bytes", "community" => label.clone()) .set(storage.bytes as f64); - metrics::gauge!("buzz_community_storage_objects", "community" => host.clone()) + metrics::gauge!("buzz_community_storage_objects", "community" => label.clone()) .set(storage.objects as f64); - current.insert(StorageEmittedKey::Bytes(host.clone())); - current.insert(StorageEmittedKey::Objects(host.clone())); + current.insert(StorageEmittedKey::Bytes(label.clone())); + current.insert(StorageEmittedKey::Objects(label)); } metrics::gauge!("buzz_storage_unmapped_community_bytes").set(unmapped_bytes as f64); @@ -982,6 +984,9 @@ mod tests { }); let recorder = DebuggingRecorder::new(); + let label_a = crate::metrics::community_label(CommunityId::from_uuid(community_a)); + let label_b = crate::metrics::community_label(CommunityId::from_uuid(community_b)); + let label_c = crate::metrics::community_label(CommunityId::from_uuid(community_c)); // --- Emission 1: all three communities visible --- let mut host_map_1 = HashMap::new(); @@ -994,18 +999,12 @@ mod tests { { let labeled = labeled_community_gauges(&recorder); assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.a".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_a.clone())), Some(&10.0), "emission 1: host.a bytes should be 10" ); assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.old".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_b.clone())), Some(&20.0), "emission 1: host.old bytes should be 20" ); @@ -1033,56 +1032,36 @@ mod tests { let labeled = labeled_community_gauges(&recorder); - // (a) community_a disappeared — old host.a series must be zeroed + // (a) community_a disappeared — its pseudonymous series is zeroed. assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.a".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_a.clone())), Some(&0.0), "(a) disappeared community: host.a bytes must be zeroed" ); assert_eq!( labeled.get(&( "buzz_community_storage_objects".to_string(), - "host.a".to_string() + label_a.clone() )), Some(&0.0), "(a) disappeared community: host.a objects must be zeroed" ); - // (b) community_b renamed host.old → host.new — old series must be zeroed - assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.old".to_string() - )), - Some(&0.0), - "(b) host rename: host.old bytes must be zeroed" - ); + // (b) a host rename retains the same non-host label and value. assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.new".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_b.clone())), Some(&20.0), - "(b) host rename: host.new bytes must be 20" + "(b) host rename must not expose or churn a tenant-host label" ); // (c) community_c scope-excluded — host.c series must be zeroed assert_eq!( - labeled.get(&( - "buzz_community_storage_bytes".to_string(), - "host.c".to_string() - )), + labeled.get(&("buzz_community_storage_bytes".to_string(), label_c.clone())), Some(&0.0), "(c) scope removal: host.c bytes must be zeroed" ); assert_eq!( - labeled.get(&( - "buzz_community_storage_objects".to_string(), - "host.c".to_string() - )), + labeled.get(&("buzz_community_storage_objects".to_string(), label_c)), Some(&0.0), "(c) scope removal: host.c objects must be zeroed" ); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..9bec1be734 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -19,6 +19,73 @@ use uuid::Uuid; use crate::handlers::event::dispatch_persistent_event; use crate::state::AppState; +/// Relay-owned provider-neutral workflow mutation gate. +/// +/// The weak application-state reference avoids a cycle through +/// `AppState -> WorkflowEngine -> MutationGate -> AppState`. +pub struct RelayWorkflowMutationGate { + state: Weak, +} + +impl RelayWorkflowMutationGate { + /// Create a gate backed by the relay's immutable protected-domain policy. + pub fn new(state: &Arc) -> Self { + Self { + state: Arc::downgrade(state), + } + } +} + +impl buzz_workflow::MutationGate for RelayWorkflowMutationGate { + fn require_mutation( + &self, + community_id: CommunityId, + ) -> Result<(), buzz_workflow::WorkflowError> { + let state = self.state.upgrade().ok_or_else(|| { + buzz_workflow::WorkflowError::Unauthorized( + "protected workflow mutation unavailable".into(), + ) + })?; + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + crate::protected_surface::require_effect_permit( + mode, + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .map(|_| ()) + .map_err(|_| { + buzz_workflow::WorkflowError::Unauthorized( + "protected workflow mutation unavailable".into(), + ) + }) + } + + fn require_outbound_webhook( + &self, + community_id: CommunityId, + ) -> Result<(), buzz_workflow::WorkflowError> { + let state = self.state.upgrade().ok_or_else(|| { + buzz_workflow::WorkflowError::Unauthorized( + "protected outbound webhook unavailable".into(), + ) + })?; + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + crate::protected_surface::require_effect_permit( + mode, + crate::protected_surface::EffectSurfaceId::OutboundWebhook, + ) + .map(|_| ()) + .map_err(|_| { + buzz_workflow::WorkflowError::Unauthorized( + "protected outbound webhook unavailable".into(), + ) + }) + } +} + /// Resolves `@Name` mentions in workflow message text to the pubkeys of the /// channel members they name, so the emitted kind:9 carries the `p` tags that /// ACP agent-wake (`event_mentions_agent`) is gated on. @@ -188,6 +255,17 @@ impl ActionSink for RelayActionSink { .upgrade() .ok_or_else(|| ActionSinkError::Database("relay is shutting down".into()))?; + // A delayed action may outlive the authority that started its run. + // With no transaction-owning workflow executor, Enforce must stop + // before tenant lookup, event construction, persistence, or fanout. + crate::authorization_runtime::transport::require_unwired_atomic_mutation_if_configured( + &state, + community_id, + ) + .map_err(|_| { + ActionSinkError::Database("protected workflow mutation unavailable".into()) + })?; + // The run carries its owning community (`community_id`); the // relay-signed kind:9 message belongs to *that* community, never the // deployment default. Re-deriving the tenant from `config.relay_url` @@ -567,10 +645,41 @@ mod integration_tests { //! Postgres-gated like the other DB-backed relay tests. Run with: //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` use super::*; + use async_trait::async_trait; + use buzz_auth::{AuthorizationClock, AuthorizationClockError, AuthorizationTime}; use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; use buzz_db::CreateCommunityWithOwnerResult; use std::sync::Arc; + struct UnavailableResolver; + + #[async_trait] + impl crate::authorization_runtime::transport::ProtectedAuthorizationResolver + for UnavailableResolver + { + async fn resolve( + &self, + _request: &crate::authorization_runtime::transport::ProtectedOperationRequest, + ) -> Result< + crate::authorization_runtime::transport::ProtectedResolution, + crate::authorization_runtime::transport::ProtectedResolutionError, + > { + Err( + crate::authorization_runtime::transport::ProtectedResolutionError::new( + "synthetic_unavailable", + ), + ) + } + } + + struct FixedClock; + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(100)) + } + } + /// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`. async fn test_state() -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); @@ -609,6 +718,75 @@ mod integration_tests { Arc::new(state) } + #[tokio::test] + async fn workflow_action_enforce_without_executor_persists_no_event() { + let state = test_state().await; + let community = CommunityId::from_uuid(Uuid::from_u128(0xF10)); + let runtime = crate::authorization_runtime::transport::ProtectedTransportRuntime::new( + [ + crate::authorization_runtime::transport::DomainTransportPolicy::from_server_configuration( + community, + crate::authorization_runtime::finalization::AuthorizationMode::Enforce, + ), + ], + Arc::new(UnavailableResolver), + Arc::new(FixedClock), + ) + .expect("synthetic protected runtime"); + state + .install_protected_transport(Arc::new(runtime)) + .expect("install protected runtime once"); + state + .workflow_engine + .set_mutation_gate(Arc::new(RelayWorkflowMutationGate::new(&state))); + + let trigger = buzz_workflow::executor::TriggerContext { + message_id: "synthetic-event".into(), + ..Default::default() + }; + for action in [ + buzz_workflow::ActionDef::AddReaction { + emoji: "check".into(), + }, + buzz_workflow::ActionDef::CallWebhook { + url: "https://example.invalid/hook".into(), + method: None, + headers: None, + body: None, + }, + ] { + let error = buzz_workflow::executor::dispatch_action( + "blocked", + &action, + &state.workflow_engine, + community, + Uuid::from_u128(2), + &trigger, + ) + .await + .expect_err("direct workflow effects must stop at the central gate"); + assert!(matches!( + error, + buzz_workflow::WorkflowError::Unauthorized(_) + )); + } + + let error = RelayActionSink::new(&state) + .send_message( + community, + &Uuid::from_u128(1).to_string(), + "must not persist", + &nostr::Keys::generate().public_key().to_hex(), + ) + .await + .expect_err("Enforce without an executor must fail before persistence"); + + assert_eq!( + error.to_string(), + "database error: protected workflow mutation unavailable" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn workflow_send_message_p_tags_mentioned_member() { diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index e30541377e..6fe2c44763 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -526,6 +526,16 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; + // This is the final common boundary for every action, including effects + // that do not use the relay ActionSink. A delayed run must therefore pass + // the embedding relay's current mutation gate again immediately before + // SendMessage, AddReaction, CallWebhook, or any future action dispatch. + if matches!(action, CallWebhook { .. }) { + engine.require_outbound_webhook(community_id)?; + } else { + engine.require_mutation(community_id)?; + } + match action { SendMessage { text, channel } => { // Look up workflow metadata for destination validation and @@ -982,6 +992,10 @@ pub async fn execute_run( ) })?; + engine + .require_mutation(community_id) + .map_err(|error| (error, crate::error::PartialProgress::default()))?; + engine .db .update_workflow_run( @@ -1032,6 +1046,10 @@ pub async fn execute_from_step( ) })?; + engine + .require_mutation(community_id) + .map_err(|error| (error, crate::error::PartialProgress::default()))?; + // Mark run as Running now that we have a permit (resume from approval). // Preserve the existing execution trace from pre-approval steps. let existing_trace = match engine.db.get_workflow_run(community_id, run_id).await { diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d1702..494a835023 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -33,11 +33,13 @@ pub mod action_sink; pub mod error; pub mod executor; +pub mod mutation_gate; pub mod schema; pub use action_sink::{ActionSink, ActionSinkError}; pub use error::{PartialProgress, WorkflowError}; pub use executor::ExecutionResult; +pub use mutation_gate::MutationGate; pub use schema::{ActionDef, Step, TriggerDef, WorkflowDef}; use std::collections::HashMap; @@ -87,6 +89,9 @@ pub struct WorkflowEngine { /// Action sink for executing side-effects (SendMessage, etc.). /// Late-initialized via [`set_action_sink`] after `AppState` construction. pub(crate) action_sink: OnceLock>, + /// Provider-neutral gate evaluated before every mutation or external effect. + /// Late-initialized by the embedding relay after `AppState` construction. + pub(crate) mutation_gate: OnceLock>, /// Short-TTL cache for the per-event enabled-workflow lookup, keyed /// `(community_id, channel_id)`. Most channels have no workflows, so this /// removes one SELECT from nearly every ingested event. @@ -115,6 +120,7 @@ impl WorkflowEngine { run_semaphore, last_fired: DashMap::new(), action_sink: OnceLock::new(), + mutation_gate: OnceLock::new(), workflow_cache: moka::sync::Cache::builder() .max_capacity(10_000) .time_to_live(std::time::Duration::from_secs(10)) @@ -180,6 +186,38 @@ impl WorkflowEngine { } } + /// Set the workflow mutation gate. Called once by an embedding relay. + /// + /// # Panics + /// Panics if called more than once. + pub fn set_mutation_gate(&self, gate: Arc) { + if self.mutation_gate.set(gate).is_err() { + panic!("mutation_gate already initialized"); + } + } + + /// Require current authority before a workflow mutation or external effect. + /// + /// A standalone engine with no installed gate preserves legacy behavior. + /// Once an embedding relay installs a gate, every engine-owned mutation + /// door calls this method before touching durable or external state. + pub(crate) fn require_mutation(&self, community_id: CommunityId) -> Result<(), WorkflowError> { + mutation_gate::require_configured_mutation( + self.mutation_gate.get().map(AsRef::as_ref), + community_id, + ) + } + + pub(crate) fn require_outbound_webhook( + &self, + community_id: CommunityId, + ) -> Result<(), WorkflowError> { + mutation_gate::require_configured_outbound_webhook( + self.mutation_gate.get().map(AsRef::as_ref), + community_id, + ) + } + /// Get the action sink reference. /// /// Returns `Err(WorkflowError)` if the sink has not been initialized via diff --git a/crates/buzz-workflow/src/mutation_gate.rs b/crates/buzz-workflow/src/mutation_gate.rs new file mode 100644 index 0000000000..bed8666143 --- /dev/null +++ b/crates/buzz-workflow/src/mutation_gate.rs @@ -0,0 +1,77 @@ +//! Provider-neutral admission gate for workflow mutations and external effects. + +use buzz_core::tenant::CommunityId; + +use crate::WorkflowError; + +/// Server-owned gate evaluated before every workflow mutation or external effect. +/// +/// The workflow engine deliberately knows nothing about identity providers, +/// leases, or deployment configuration. A relay can install a gate that denies +/// an authorization domain until it has a transaction-owning executor. When no +/// gate is installed, the standalone engine preserves its legacy behavior. +pub trait MutationGate: Send + Sync { + /// Require current authority for one server-resolved authorization domain. + fn require_mutation(&self, community_id: CommunityId) -> Result<(), WorkflowError>; + + /// Require authority for an outbound network effect. + fn require_outbound_webhook(&self, community_id: CommunityId) -> Result<(), WorkflowError> { + self.require_mutation(community_id) + } +} + +pub(crate) fn require_configured_mutation( + gate: Option<&dyn MutationGate>, + community_id: CommunityId, +) -> Result<(), WorkflowError> { + match gate { + Some(gate) => gate.require_mutation(community_id), + None => Ok(()), + } +} + +pub(crate) fn require_configured_outbound_webhook( + gate: Option<&dyn MutationGate>, + community_id: CommunityId, +) -> Result<(), WorkflowError> { + match gate { + Some(gate) => gate.require_outbound_webhook(community_id), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + struct DenyGate(AtomicUsize); + + impl MutationGate for DenyGate { + fn require_mutation(&self, _community_id: CommunityId) -> Result<(), WorkflowError> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(WorkflowError::Unauthorized( + "synthetic mutation denial".into(), + )) + } + } + + #[test] + fn absent_gate_preserves_legacy_and_configured_denial_is_authoritative() { + let community_id = CommunityId::from_uuid(uuid::Uuid::from_u128(1)); + assert!(require_configured_mutation(None, community_id).is_ok()); + + let gate = DenyGate(AtomicUsize::new(0)); + assert!(matches!( + require_configured_mutation(Some(&gate), community_id), + Err(WorkflowError::Unauthorized(_)) + )); + assert_eq!(gate.0.load(Ordering::SeqCst), 1); + assert!(matches!( + require_configured_outbound_webhook(Some(&gate), community_id), + Err(WorkflowError::Unauthorized(_)) + )); + assert_eq!(gate.0.load(Ordering::SeqCst), 2); + } +}