diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 7748941d59..9368f539c0 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -40,6 +40,45 @@ fn request_path(path: &str, raw_query: Option<&str>) -> String { } } +fn ensure_channel_access( + accessible: &[Uuid], + channel_id: Uuid, + error: &'static str, +) -> Result<(), (StatusCode, Json)> { + if accessible.contains(&channel_id) { + Ok(()) + } else { + Err(api_error(StatusCode::FORBIDDEN, error)) + } +} + +async fn enforce_current_channel_read( + state: &Arc, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: &nostr::PublicKey, + channel_id: Uuid, + error: &'static str, +) -> Result<(), (StatusCode, Json)> { + let pubkey_bytes = pubkey.to_bytes().to_vec(); + let auth_tag = headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()); + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; + + let accessible = state + .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) + .await + .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; + ensure_channel_access(&accessible, channel_id, error) +} + async fn authorize_workflow_read( state: &Arc, headers: &HeaderMap, @@ -94,16 +133,15 @@ async fn authorize_workflow_read( let channel_id = workflow .channel_id .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?; - let accessible = state - .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) - .await - .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; - if !accessible.contains(&channel_id) { - return Err(api_error( - StatusCode::FORBIDDEN, - "workflow is not accessible", - )); - } + enforce_current_channel_read( + state, + &tenant, + headers, + &pubkey, + channel_id, + "workflow is not accessible", + ) + .await?; Ok(tenant) } @@ -288,6 +326,15 @@ pub async fn workflow_wake_authority( let message_channel = message .channel_id .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "workflow wake not found"))?; + enforce_current_channel_read( + &state, + &tenant, + &headers, + &recipient, + message_channel, + "workflow wake not accessible", + ) + .await?; if workflow.owner_pubkey != definition.event.pubkey.to_bytes() || workflow.channel_id != Some(message_channel) || !exact_tag(&definition.event, "h", &message_channel.to_string()) @@ -334,6 +381,16 @@ mod tests { ); } + #[test] + fn channel_access_is_required_at_authority_read_time() { + let channel = Uuid::new_v4(); + assert!(ensure_channel_access(&[channel], channel, "revoked").is_ok()); + + let (status, _) = ensure_channel_access(&[], channel, "revoked") + .expect_err("removed member must not retain authority read access"); + assert_eq!(status, StatusCode::FORBIDDEN); + } + #[test] fn approval_wire_does_not_expose_hash_as_token() { let approval = buzz_db::workflow::ApprovalRecord {