From 16533c97be8b17623f5c832c871050189f46c00c Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Tue, 5 May 2026 15:35:14 +0000 Subject: [PATCH 1/6] feat: multi-tenant relay channel with per-user identity resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire PairingStore into RelayChannel so incoming Slack events resolve the sender_id to an internal IronClaw UserId. This enables multi-tenant IronClaw where multiple users each have their own Slack connection. Key changes: - RelayChannel resolves sender_id → internal UserId via PairingStore on every incoming event (falls back to raw sender_id for single-tenant) - OAuth callback creates a channel_identity pairing between the Slack authed_user_id and the IronClaw user who initiated the OAuth flow - ExtensionManager stores oauth_user during OAuth initiation so the callback knows which user to pair - PairingStore gains create_identity() for trusted OAuth-based pairing - Both DB backends (postgres + libsql) implement create_channel_identity - RelayClient passes slack_user_id on proxy calls for display name Depends on: nearai/channel-relay#14 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 7 +++ src/channels/relay/channel.rs | 63 ++++++++++++++++++++++++-- src/channels/relay/client.rs | 17 ++++++- src/channels/web/features/oauth/mod.rs | 43 ++++++++++++++++++ src/db/libsql/pairing.rs | 21 +++++++++ src/db/mod.rs | 9 ++++ src/db/postgres.rs | 25 ++++++++++ src/extensions/manager.rs | 31 ++++++++++++- src/pairing/store.rs | 18 ++++++++ 9 files changed, 227 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index b66df7b6a97..94a96d90165 100644 --- a/src/app.rs +++ b/src/app.rs @@ -989,6 +989,13 @@ impl AppBuilder { if let Some(ref ss) = settings_store_override { em = em.with_settings_store(Arc::clone(ss)); } + if let Some(ref db) = self.db { + let ps = Arc::new(crate::pairing::PairingStore::new( + Arc::clone(db), + Arc::new(crate::ownership::OwnershipCache::new()), + )); + em = em.with_pairing_store(ps); + } let manager = Arc::new(em); tools.register_extension_tools(Arc::clone(&manager)); diff --git a/src/channels/relay/channel.rs b/src/channels/relay/channel.rs index 192e48d00da..ff27fb991e0 100644 --- a/src/channels/relay/channel.rs +++ b/src/channels/relay/channel.rs @@ -6,6 +6,7 @@ //! proxy API (Slack). use std::collections::HashMap; +use std::sync::Arc; use async_trait::async_trait; use tokio::sync::mpsc; @@ -15,6 +16,7 @@ use crate::channels::{ Channel, ChatApprovalPrompt, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate, }; use crate::error::ChannelError; +use crate::pairing::PairingStore; /// Default channel name for the Slack relay integration. pub const DEFAULT_RELAY_NAME: &str = "slack-relay"; @@ -51,6 +53,8 @@ pub struct RelayChannel { event_tx: mpsc::Sender, /// Receiver side — taken once by `start()`. event_rx: tokio::sync::Mutex>>, + /// Resolves Slack sender_id → internal UserId for multi-tenant support. + pairing_store: Option>, } impl RelayChannel { @@ -88,9 +92,16 @@ impl RelayChannel { instance_id, event_tx, event_rx: tokio::sync::Mutex::new(Some(event_rx)), + pairing_store: None, } } + /// Set the pairing store for multi-tenant identity resolution. + pub fn with_pairing_store(mut self, store: Arc) -> Self { + self.pairing_store = Some(store); + self + } + /// Get a clone of the event sender for wiring into the webhook endpoint. pub fn event_sender(&self) -> mpsc::Sender { self.event_tx.clone() @@ -123,9 +134,16 @@ impl RelayChannel { team_id: &str, method: &str, body: serde_json::Value, + slack_user_id: Option<&str>, ) -> Result { self.client - .proxy_provider(self.provider.as_str(), team_id, method, body) + .proxy_provider_with_user( + self.provider.as_str(), + team_id, + method, + body, + slack_user_id, + ) .await } @@ -205,6 +223,7 @@ impl Channel for RelayChannel { let (tx, rx) = mpsc::channel(64); let provider_str = self.provider.as_str().to_string(); let relay_name = channel_name.clone(); + let pairing_store = self.pairing_store.clone(); // Spawn a task that reads events from the webhook handler and converts to IncomingMessage tokio::spawn(async move { @@ -240,7 +259,40 @@ impl Channel for RelayChannel { "Relay: received message from {}", provider_str ); - let mut msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text()) + // Resolve sender_id → internal UserId via PairingStore. + // Falls back to raw sender_id for backward compat (single-tenant). + let resolved_user_id: String = if let Some(ref store) = pairing_store { + match store.resolve_identity(&relay_name, &event.sender_id).await { + Ok(Some(uid)) => { + let user_str = uid.as_str().to_string(); + tracing::debug!( + sender_id = %event.sender_id, + resolved_user = %user_str, + "Relay: resolved sender to internal user" + ); + user_str + } + Ok(None) => { + tracing::debug!( + sender_id = %event.sender_id, + "Relay: sender not paired, using raw sender_id" + ); + event.sender_id.clone() + } + Err(e) => { + tracing::warn!( + sender_id = %event.sender_id, + error = %e, + "Relay: pairing resolution failed, using raw sender_id" + ); + event.sender_id.clone() + } + } + } else { + event.sender_id.clone() + }; + + let mut msg = IncomingMessage::new(&relay_name, &resolved_user_id, event.text()) .with_user_name(event.display_name()) .with_metadata(serde_json::json!({ "team_id": event.team_id(), @@ -323,8 +375,9 @@ impl Channel for RelayChannel { .filter(|s| !s.is_empty()); let (method, body) = self.build_send_body(channel_id, &response.content, thread_id); + let sender_id = metadata.get("sender_id").and_then(|v| v.as_str()); - self.proxy_send(team_id, &method, body) + self.proxy_send(team_id, &method, body, sender_id) .await .map_err(|e| ChannelError::SendFailed { name: channel_name, @@ -384,7 +437,7 @@ impl Channel for RelayChannel { })?; let body = self.build_approval_body(channel_id, thread_id, &prompt, &approval_token); - self.proxy_send(team_id, "chat.postMessage", body) + self.proxy_send(team_id, "chat.postMessage", body, None) .await .map_err(|e| ChannelError::SendFailed { name: self.name().to_string(), @@ -410,7 +463,7 @@ impl Channel for RelayChannel { let (method, body) = self.build_send_body(target, &response.content, thread_id); - self.proxy_send(&self.team_id, &method, body) + self.proxy_send(&self.team_id, &method, body, None) .await .map_err(|e| ChannelError::SendFailed { name: channel_name, diff --git a/src/channels/relay/client.rs b/src/channels/relay/client.rs index 16f40f66474..a5c767fdfc1 100644 --- a/src/channels/relay/client.rs +++ b/src/channels/relay/client.rs @@ -237,6 +237,18 @@ impl RelayClient { team_id: &str, method: &str, body: serde_json::Value, + ) -> Result { + self.proxy_provider_with_user(provider, team_id, method, body, None) + .await + } + + pub async fn proxy_provider_with_user( + &self, + provider: &str, + team_id: &str, + method: &str, + body: serde_json::Value, + slack_user_id: Option<&str>, ) -> Result { let url = format!("{}/proxy/{}/{}", self.base_url, provider, method); tracing::trace!( @@ -245,7 +257,10 @@ impl RelayClient { method = %method, "RelayClient::proxy_provider: sending request" ); - let query: Vec<(&str, &str)> = vec![("team_id", team_id)]; + let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)]; + if let Some(uid) = slack_user_id { + query.push(("slack_user_id", uid)); + } let resp = self .http .post(&url) diff --git a/src/channels/web/features/oauth/mod.rs b/src/channels/web/features/oauth/mod.rs index 52821154d73..089b43f31f2 100644 --- a/src/channels/web/features/oauth/mod.rs +++ b/src/channels/web/features/oauth/mod.rs @@ -692,6 +692,8 @@ pub(crate) async fn slack_relay_oauth_callback_handler( .into_response(); } + let authed_user_id = params.get("authed_user_id").cloned().unwrap_or_default(); + let result: Result<(), String> = async { let store = state.store.as_ref().ok_or_else(|| { "Relay activation requires persistent settings storage; no-db mode is unsupported." @@ -719,6 +721,47 @@ pub(crate) async fn slack_relay_oauth_callback_handler( format!("Failed to persist relay team_id: {e}") })?; + // Create channel identity pairing: Slack authed_user_id → IronClaw user. + // The oauth_user secret was stored during auth_channel_relay() and identifies + // which IronClaw user initiated this OAuth flow. + if !authed_user_id.is_empty() && let Some(pairing_store) = ext_mgr.pairing_store() { + let user_key = format!("relay:{}:oauth_user", relay_extension_name); + let oauth_user = ext_mgr + .secrets() + .get_decrypted(&state.owner_id, &user_key) + .await + .ok() + .map(|s| s.expose().to_string()) + .unwrap_or_else(|| state.owner_id.clone()); + let _ = ext_mgr.secrets().delete(&state.owner_id, &user_key).await; + + let Ok(user_id) = crate::ownership::UserId::new( + &oauth_user, + crate::ownership::UserRole::Regular, + ) else { + tracing::warn!( + oauth_user = %oauth_user, + "relay OAuth callback: invalid user_id for channel identity" + ); + return Ok(()); + }; + if let Err(e) = pairing_store + .create_identity( + crate::channels::relay::channel::DEFAULT_RELAY_NAME, + &authed_user_id, + &user_id, + ) + .await + { + tracing::warn!( + authed_user_id = %authed_user_id, + oauth_user = %oauth_user, + error = %e, + "relay OAuth callback: failed to create channel identity" + ); + } + } + // Activate the relay channel tracing::info!( relay = %relay_extension_name, diff --git a/src/db/libsql/pairing.rs b/src/db/libsql/pairing.rs index 7b114e8d58c..bf942b7dd02 100644 --- a/src/db/libsql/pairing.rs +++ b/src/db/libsql/pairing.rs @@ -462,6 +462,27 @@ impl ChannelPairingStore for LibSqlBackend { .map_err(|e| DatabaseError::Query(e.to_string()))?; Ok(()) } + + async fn create_channel_identity( + &self, + channel: &str, + external_id: &str, + owner_id: &str, + ) -> Result<(), DatabaseError> { + let channel = crate::pairing::normalize_channel_name(channel); + let id = uuid::Uuid::new_v4().to_string(); + let conn = self.connect().await?; + conn.execute( + "INSERT INTO channel_identities (id, owner_id, channel, external_id) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (channel, external_id) + DO UPDATE SET owner_id = ?2", + params![id, owner_id, channel, external_id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } } #[cfg(test)] diff --git a/src/db/mod.rs b/src/db/mod.rs index 7b605773ace..272245df731 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1192,6 +1192,15 @@ pub trait ChannelPairingStore: Send + Sync { channel: &str, external_id: &str, ) -> Result<(), DatabaseError>; + + /// Create or update a channel identity directly (trusted path, e.g. OAuth). + /// Inserts into channel_identities without requiring a pairing code. + async fn create_channel_identity( + &self, + channel: &str, + external_id: &str, + owner_id: &str, + ) -> Result<(), DatabaseError>; } /// Generates an 8-character pairing code from an unambiguous alphabet. diff --git a/src/db/postgres.rs b/src/db/postgres.rs index fda565eeb3c..c7621bd8d62 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -1467,6 +1467,31 @@ impl ChannelPairingStore for PgBackend { .map_err(|e| DatabaseError::Query(e.to_string()))?; Ok(()) } + + async fn create_channel_identity( + &self, + channel: &str, + external_id: &str, + owner_id: &str, + ) -> Result<(), DatabaseError> { + let channel = crate::pairing::normalize_channel_name(channel); + let client = self + .pool() + .get() + .await + .map_err(|e| DatabaseError::Pool(e.to_string()))?; + client + .execute( + "INSERT INTO channel_identities (owner_id, channel, external_id) + VALUES ($1, $2, $3) + ON CONFLICT (channel, external_id) + DO UPDATE SET owner_id = $1", + &[&owner_id, &channel, &external_id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } } // ==================== IdentityStore ==================== diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 14df7552477..c16ff9dbff6 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -441,6 +441,8 @@ pub struct ExtensionManager { /// Stored here so the web gateway can verify incoming callbacks without /// any env var or shared secret. relay_signing_secret_cache: Arc>>>, + /// PairingStore for multi-tenant relay identity resolution. + pairing_store: Option>, /// When `true`, OAuth flows always return an auth URL to the caller /// instead of opening a browser on the server via `open::that()`. /// Set by the web gateway at startup via `enable_gateway_mode()`. @@ -670,6 +672,7 @@ impl ExtensionManager { relay_config: crate::config::RelayConfig::from_env(), relay_event_tx: Arc::new(tokio::sync::Mutex::new(None)), relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)), + pairing_store: None, gateway_mode: std::sync::atomic::AtomicBool::new(false), gateway_base_url: RwLock::new(None), channel_activation_locks: RwLock::new(HashMap::new()), @@ -1156,6 +1159,10 @@ impl ExtensionManager { &self.secrets } + pub fn pairing_store(&self) -> Option<&Arc> { + self.pairing_store.as_ref() + } + /// Expose the per-user MCP client store. Tool wrappers registered in /// the global `ToolRegistry` hold an `Arc` and resolve /// the caller's client at dispatch time via @@ -1412,6 +1419,11 @@ impl ExtensionManager { self } + pub fn with_pairing_store(mut self, store: Arc) -> Self { + self.pairing_store = Some(store); + self + } + async fn clear_pending_extension_auth(&self, name: &str, user_id: &str) { { let mut pending = self.pending_auth.write().await; @@ -6462,6 +6474,20 @@ impl ExtensionManager { ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")) })?; + // Store the initiating user_id so the OAuth callback knows which IronClaw + // user to pair with the Slack authed_user_id. + let user_key = format!("relay:{}:oauth_user", name); + let _ = self.secrets.delete(&self.user_id, &user_key).await; + self.secrets + .create( + &self.user_id, + CreateSecretParams::new(&user_key, user_id), + ) + .await + .map_err(|e| { + ExtensionError::AuthFailed(format!("Failed to store OAuth user: {e}")) + })?; + // Channel-relay derives all URLs from trusted instance_url in chat-api. // We only pass the nonce for CSRF validation on the callback. tracing::trace!( @@ -6612,7 +6638,7 @@ impl ExtensionManager { // Create the event channel for webhook callbacks let (event_tx, event_rx) = tokio::sync::mpsc::channel(64); - let channel = crate::channels::relay::RelayChannel::new_with_provider( + let mut channel = crate::channels::relay::RelayChannel::new_with_provider( client.clone(), crate::channels::relay::channel::RelayProvider::Slack, team_id.clone(), @@ -6620,6 +6646,9 @@ impl ExtensionManager { event_tx.clone(), event_rx, ); + if let Some(ref ps) = self.pairing_store { + channel = channel.with_pairing_store(Arc::clone(ps)); + } // Hot-add to channel manager let cm_guard = self.relay_channel_manager.read().await; diff --git a/src/pairing/store.rs b/src/pairing/store.rs index 77777f4335d..932fc129c4f 100644 --- a/src/pairing/store.rs +++ b/src/pairing/store.rs @@ -197,4 +197,22 @@ impl PairingStore { self.cache.evict(&channel, external_id); Ok(()) } + + /// Create a channel identity directly (trusted path, e.g. OAuth completion). + /// Inserts into channel_identities and populates the cache without requiring + /// a pairing code flow. + pub async fn create_identity( + &self, + channel: &str, + external_id: &str, + owner_id: &UserId, + ) -> Result<(), DatabaseError> { + let channel = crate::pairing::normalize_channel_name(channel); + if let Some(ref db) = self.db { + db.create_channel_identity(&channel, external_id, owner_id.as_str()) + .await?; + } + self.cache.insert(&channel, external_id, owner_id.clone()); + Ok(()) + } } From 4d6d27635c8f2bffc89a8af4778752519c091a62 Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Tue, 5 May 2026 16:46:25 +0000 Subject: [PATCH 2/6] style: fix cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/relay/channel.rs | 8 +------- src/channels/web/features/oauth/mod.rs | 11 ++++++----- src/extensions/manager.rs | 9 ++------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/channels/relay/channel.rs b/src/channels/relay/channel.rs index ff27fb991e0..157d6271c8f 100644 --- a/src/channels/relay/channel.rs +++ b/src/channels/relay/channel.rs @@ -137,13 +137,7 @@ impl RelayChannel { slack_user_id: Option<&str>, ) -> Result { self.client - .proxy_provider_with_user( - self.provider.as_str(), - team_id, - method, - body, - slack_user_id, - ) + .proxy_provider_with_user(self.provider.as_str(), team_id, method, body, slack_user_id) .await } diff --git a/src/channels/web/features/oauth/mod.rs b/src/channels/web/features/oauth/mod.rs index 089b43f31f2..eb3600421e9 100644 --- a/src/channels/web/features/oauth/mod.rs +++ b/src/channels/web/features/oauth/mod.rs @@ -724,7 +724,9 @@ pub(crate) async fn slack_relay_oauth_callback_handler( // Create channel identity pairing: Slack authed_user_id → IronClaw user. // The oauth_user secret was stored during auth_channel_relay() and identifies // which IronClaw user initiated this OAuth flow. - if !authed_user_id.is_empty() && let Some(pairing_store) = ext_mgr.pairing_store() { + if !authed_user_id.is_empty() + && let Some(pairing_store) = ext_mgr.pairing_store() + { let user_key = format!("relay:{}:oauth_user", relay_extension_name); let oauth_user = ext_mgr .secrets() @@ -735,10 +737,9 @@ pub(crate) async fn slack_relay_oauth_callback_handler( .unwrap_or_else(|| state.owner_id.clone()); let _ = ext_mgr.secrets().delete(&state.owner_id, &user_key).await; - let Ok(user_id) = crate::ownership::UserId::new( - &oauth_user, - crate::ownership::UserRole::Regular, - ) else { + let Ok(user_id) = + crate::ownership::UserId::new(&oauth_user, crate::ownership::UserRole::Regular) + else { tracing::warn!( oauth_user = %oauth_user, "relay OAuth callback: invalid user_id for channel identity" diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index c16ff9dbff6..95cc6040099 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -6479,14 +6479,9 @@ impl ExtensionManager { let user_key = format!("relay:{}:oauth_user", name); let _ = self.secrets.delete(&self.user_id, &user_key).await; self.secrets - .create( - &self.user_id, - CreateSecretParams::new(&user_key, user_id), - ) + .create(&self.user_id, CreateSecretParams::new(&user_key, user_id)) .await - .map_err(|e| { - ExtensionError::AuthFailed(format!("Failed to store OAuth user: {e}")) - })?; + .map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth user: {e}")))?; // Channel-relay derives all URLs from trusted instance_url in chat-api. // We only pass the nonce for CSRF validation on the callback. From 89c9e23a480cf6eef01a5c66daa1f309655ba1f5 Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Tue, 5 May 2026 16:59:13 +0000 Subject: [PATCH 3/6] fix: preserve user role when creating relay channel identity Look up the actual user role from the DB instead of hardcoding UserRole::Regular. Prevents owner/admin capabilities from being silently stripped when their Slack identity is cached via PairingStore. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/web/features/oauth/mod.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/channels/web/features/oauth/mod.rs b/src/channels/web/features/oauth/mod.rs index eb3600421e9..befeb5acab6 100644 --- a/src/channels/web/features/oauth/mod.rs +++ b/src/channels/web/features/oauth/mod.rs @@ -737,9 +737,21 @@ pub(crate) async fn slack_relay_oauth_callback_handler( .unwrap_or_else(|| state.owner_id.clone()); let _ = ext_mgr.secrets().delete(&state.owner_id, &user_key).await; - let Ok(user_id) = - crate::ownership::UserId::new(&oauth_user, crate::ownership::UserRole::Regular) - else { + let role = if let Some(ref db) = state.store { + db.get_user(&oauth_user) + .await + .ok() + .flatten() + .map(|u| match u.role.as_str() { + "owner" => crate::ownership::UserRole::Owner, + "admin" => crate::ownership::UserRole::Admin, + _ => crate::ownership::UserRole::Regular, + }) + .unwrap_or(crate::ownership::UserRole::Regular) + } else { + crate::ownership::UserRole::Regular + }; + let Ok(user_id) = crate::ownership::UserId::new(&oauth_user, role) else { tracing::warn!( oauth_user = %oauth_user, "relay OAuth callback: invalid user_id for channel identity" From f71eefdffe8ebc063ed3065a6d38da4e9704c02a Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Wed, 6 May 2026 13:16:27 +0000 Subject: [PATCH 4/6] feat: pairing code flow for unpaired Slack senders When an unpaired Slack user DMs the bot, generate a pairing code via PairingStore and reply with instructions. The user enters the code in the IronClaw web UI to pair their account. Reuses the existing WASM channel pairing infrastructure (pairing_requests table, /api/pairing endpoint). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/relay/channel.rs | 42 ++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/channels/relay/channel.rs b/src/channels/relay/channel.rs index 157d6271c8f..3393852c7f2 100644 --- a/src/channels/relay/channel.rs +++ b/src/channels/relay/channel.rs @@ -218,6 +218,7 @@ impl Channel for RelayChannel { let provider_str = self.provider.as_str().to_string(); let relay_name = channel_name.clone(); let pairing_store = self.pairing_store.clone(); + let pairing_client = self.client.clone(); // Spawn a task that reads events from the webhook handler and converts to IncomingMessage tokio::spawn(async move { @@ -267,11 +268,46 @@ impl Channel for RelayChannel { user_str } Ok(None) => { - tracing::debug!( + tracing::info!( sender_id = %event.sender_id, - "Relay: sender not paired, using raw sender_id" + "Relay: sender not paired, sending pairing code" ); - event.sender_id.clone() + let meta = serde_json::json!({ + "sender_name": event.display_name(), + "channel_id": event.channel_id, + }); + match store + .upsert_request(&relay_name, &event.sender_id, Some(meta)) + .await + { + Ok(record) => { + let instructions = format!( + "Enter this code in IronClaw to pair your Slack account: `{}`", + record.code + ); + let team_id = event.team_id().to_string(); + let body = serde_json::json!({ + "channel": event.channel_id, + "text": instructions, + "thread_ts": event.thread_id.as_deref().unwrap_or(&event.id), + }); + if let Err(e) = pairing_client + .proxy_provider( + &provider_str, + &team_id, + "chat.postMessage", + body, + ) + .await + { + tracing::warn!(error = %e, "Relay: failed to send pairing code reply"); + } + } + Err(e) => { + tracing::warn!(error = %e, "Relay: failed to create pairing request"); + } + } + continue; } Err(e) => { tracing::warn!( From 262f80194e7363c3939d2f748c819e291e1f575b Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Wed, 6 May 2026 13:34:19 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20security=20and=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Share OwnershipCache between gateway and ExtensionManager PairingStore so admin suspend/delete evicts relay identity cache (serrrfirat) 2. Preserve raw Slack sender_id via with_sender_id() on IncomingMessage so downstream code has both internal user_id and external actor (serrrfirat) 3. Drop messages on pairing resolution error instead of admitting raw sender_id — fail closed when PairingStore is configured (serrrfirat) 4. Verify OAuth user is active before creating relay identity (serrrfirat) 5. Fail OAuth callback when identity creation fails instead of silently continuing with partial state (serrrfirat) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 13 +++++-- src/channels/relay/channel.rs | 5 +-- src/channels/web/features/oauth/mod.rs | 50 +++++++++++++------------- 3 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/app.rs b/src/app.rs index 94a96d90165..62a3b8198a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -695,6 +695,7 @@ impl AppBuilder { tools: &Arc, hooks: &Arc, settings_store_override: Option>, + ownership_cache: Arc, ) -> Result< ( Arc, @@ -992,7 +993,7 @@ impl AppBuilder { if let Some(ref db) = self.db { let ps = Arc::new(crate::pairing::PairingStore::new( Arc::clone(db), - Arc::new(crate::ownership::OwnershipCache::new()), + Arc::clone(&ownership_cache), )); em = em.with_pairing_store(ps); } @@ -1188,6 +1189,7 @@ impl AppBuilder { _ => (None, None), }; + let ownership_cache = Arc::new(crate::ownership::OwnershipCache::new()); let ( mcp_session_manager, mcp_process_manager, @@ -1196,7 +1198,12 @@ impl AppBuilder { catalog_entries, dev_loaded_tool_names, ) = self - .init_extensions(&tools, &hooks, settings_store.clone()) + .init_extensions( + &tools, + &hooks, + settings_store.clone(), + Arc::clone(&ownership_cache), + ) .await?; // Load bootstrap-completed flag from settings so that existing users @@ -1340,7 +1347,7 @@ impl AppBuilder { catalog_entries, dev_loaded_tool_names, builder, - ownership_cache: Arc::new(crate::ownership::OwnershipCache::new()), + ownership_cache, }) } } diff --git a/src/channels/relay/channel.rs b/src/channels/relay/channel.rs index 3393852c7f2..0750d11d7db 100644 --- a/src/channels/relay/channel.rs +++ b/src/channels/relay/channel.rs @@ -313,9 +313,9 @@ impl Channel for RelayChannel { tracing::warn!( sender_id = %event.sender_id, error = %e, - "Relay: pairing resolution failed, using raw sender_id" + "Relay: pairing resolution failed, dropping message" ); - event.sender_id.clone() + continue; } } } else { @@ -323,6 +323,7 @@ impl Channel for RelayChannel { }; let mut msg = IncomingMessage::new(&relay_name, &resolved_user_id, event.text()) + .with_sender_id(event.sender_id.clone()) .with_user_name(event.display_name()) .with_metadata(serde_json::json!({ "team_id": event.team_id(), diff --git a/src/channels/web/features/oauth/mod.rs b/src/channels/web/features/oauth/mod.rs index befeb5acab6..d54531de5f0 100644 --- a/src/channels/web/features/oauth/mod.rs +++ b/src/channels/web/features/oauth/mod.rs @@ -737,42 +737,40 @@ pub(crate) async fn slack_relay_oauth_callback_handler( .unwrap_or_else(|| state.owner_id.clone()); let _ = ext_mgr.secrets().delete(&state.owner_id, &user_key).await; - let role = if let Some(ref db) = state.store { - db.get_user(&oauth_user) - .await - .ok() - .flatten() - .map(|u| match u.role.as_str() { - "owner" => crate::ownership::UserRole::Owner, - "admin" => crate::ownership::UserRole::Admin, - _ => crate::ownership::UserRole::Regular, - }) - .unwrap_or(crate::ownership::UserRole::Regular) + let user_record = if let Some(ref db) = state.store { + db.get_user(&oauth_user).await.ok().flatten() } else { - crate::ownership::UserRole::Regular + None + }; + let Some(ref record) = user_record else { + return Err(format!( + "OAuth user '{oauth_user}' not found — cannot create relay identity" + )); + }; + if record.status != "active" { + return Err(format!( + "OAuth user '{oauth_user}' is not active (status: {})", + record.status + )); + } + let role = match record.role.as_str() { + "owner" => crate::ownership::UserRole::Owner, + "admin" => crate::ownership::UserRole::Admin, + _ => crate::ownership::UserRole::Regular, }; let Ok(user_id) = crate::ownership::UserId::new(&oauth_user, role) else { - tracing::warn!( - oauth_user = %oauth_user, - "relay OAuth callback: invalid user_id for channel identity" - ); - return Ok(()); + return Err(format!( + "OAuth user '{oauth_user}' has invalid user_id format" + )); }; - if let Err(e) = pairing_store + pairing_store .create_identity( crate::channels::relay::channel::DEFAULT_RELAY_NAME, &authed_user_id, &user_id, ) .await - { - tracing::warn!( - authed_user_id = %authed_user_id, - oauth_user = %oauth_user, - error = %e, - "relay OAuth callback: failed to create channel identity" - ); - } + .map_err(|e| format!("Failed to create relay identity: {e}"))?; } // Activate the relay channel From 057596552a76b12cc9af336d2acb0faf3d04e282 Mon Sep 17 00:00:00 2001 From: Pierre LE GUEN <26087574+PierreLeGuen@users.noreply.github.com> Date: Wed, 6 May 2026 14:02:18 +0000 Subject: [PATCH 6/6] fix: address three issues introduced by multi-tenant relay 1. authed_user_id tampering: fetch from relay connections API instead of trusting the redirect URL query parameter. The relay is the authority on which Slack user completed OAuth. 2. Workspace-scoped identity: external_id in channel_identities is now "team_id:slack_user_id" instead of bare "slack_user_id". Prevents stale mappings when the relay is reconnected to a different workspace. 3. Shared OwnershipCache: create once before init_extensions and share between the gateway PairingStore and ExtensionManager PairingStore. Admin suspend/delete now evicts relay identity cache. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/relay/channel.rs | 10 ++-- src/channels/relay/client.rs | 4 ++ src/channels/web/features/oauth/mod.rs | 63 ++++++++++++++++++-------- src/extensions/manager.rs | 4 +- 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/channels/relay/channel.rs b/src/channels/relay/channel.rs index 0750d11d7db..25827cb4b87 100644 --- a/src/channels/relay/channel.rs +++ b/src/channels/relay/channel.rs @@ -255,9 +255,13 @@ impl Channel for RelayChannel { ); // Resolve sender_id → internal UserId via PairingStore. - // Falls back to raw sender_id for backward compat (single-tenant). + // External ID is scoped to workspace: "team_id:sender_id". + let scoped_external_id = format!("{}:{}", event.provider_scope, event.sender_id); let resolved_user_id: String = if let Some(ref store) = pairing_store { - match store.resolve_identity(&relay_name, &event.sender_id).await { + match store + .resolve_identity(&relay_name, &scoped_external_id) + .await + { Ok(Some(uid)) => { let user_str = uid.as_str().to_string(); tracing::debug!( @@ -277,7 +281,7 @@ impl Channel for RelayChannel { "channel_id": event.channel_id, }); match store - .upsert_request(&relay_name, &event.sender_id, Some(meta)) + .upsert_request(&relay_name, &scoped_external_id, Some(meta)) .await { Ok(record) => { diff --git a/src/channels/relay/client.rs b/src/channels/relay/client.rs index a5c767fdfc1..f09aeab1b13 100644 --- a/src/channels/relay/client.rs +++ b/src/channels/relay/client.rs @@ -81,9 +81,13 @@ impl ChannelEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Connection { pub provider: String, + #[serde(alias = "provider_scope")] pub team_id: String, + #[serde(alias = "provider_scope_name")] pub team_name: Option, + #[serde(default)] pub connected: bool, + pub authed_user_id: Option, } /// HTTP client for the channel-relay service. diff --git a/src/channels/web/features/oauth/mod.rs b/src/channels/web/features/oauth/mod.rs index d54531de5f0..d3b3fa478df 100644 --- a/src/channels/web/features/oauth/mod.rs +++ b/src/channels/web/features/oauth/mod.rs @@ -692,8 +692,6 @@ pub(crate) async fn slack_relay_oauth_callback_handler( .into_response(); } - let authed_user_id = params.get("authed_user_id").cloned().unwrap_or_default(); - let result: Result<(), String> = async { let store = state.store.as_ref().ok_or_else(|| { "Relay activation requires persistent settings storage; no-db mode is unsupported." @@ -721,12 +719,48 @@ pub(crate) async fn slack_relay_oauth_callback_handler( format!("Failed to persist relay team_id: {e}") })?; + // Activate the relay channel first — this creates the relay client and + // verifies the connection is usable. + tracing::info!( + relay = %relay_extension_name, + owner_id = %state.owner_id, + "relay OAuth callback: activating relay channel" + ); + ext_mgr + .activate_stored_relay(&relay_extension_name, &state.owner_id) + .await + .map_err(|e| format!("Failed to activate relay channel: {}", e))?; + // Create channel identity pairing: Slack authed_user_id → IronClaw user. - // The oauth_user secret was stored during auth_channel_relay() and identifies - // which IronClaw user initiated this OAuth flow. - if !authed_user_id.is_empty() - && let Some(pairing_store) = ext_mgr.pairing_store() - { + // Fetch authed_user_id from the relay's connections API (server-side, + // not from the redirect URL which could be tampered). + if let Some(pairing_store) = ext_mgr.pairing_store() { + let relay_config = ext_mgr + .relay_config() + .map_err(|e| format!("Relay config not available: {e}"))?; + let effective_url = ext_mgr + .effective_relay_url(&relay_extension_name) + .await + .unwrap_or_else(|| relay_config.url.clone()); + let client = crate::channels::relay::RelayClient::new( + effective_url, + relay_config.api_key.clone(), + relay_config.request_timeout_secs, + ) + .map_err(|e| format!("Failed to create relay client: {e}"))?; + + let connections = client + .list_connections("") + .await + .map_err(|e| format!("Failed to fetch relay connections: {e}"))?; + let authed_user_id = connections + .iter() + .find(|c| c.team_id == team_id) + .and_then(|c| c.authed_user_id.clone()) + .ok_or_else(|| { + "No connection with authed_user_id found for this team".to_string() + })?; + let user_key = format!("relay:{}:oauth_user", relay_extension_name); let oauth_user = ext_mgr .secrets() @@ -763,27 +797,18 @@ pub(crate) async fn slack_relay_oauth_callback_handler( "OAuth user '{oauth_user}' has invalid user_id format" )); }; + // Scope external_id to workspace: "team_id:slack_user_id" + let scoped_external_id = format!("{}:{}", team_id, authed_user_id); pairing_store .create_identity( crate::channels::relay::channel::DEFAULT_RELAY_NAME, - &authed_user_id, + &scoped_external_id, &user_id, ) .await .map_err(|e| format!("Failed to create relay identity: {e}"))?; } - // Activate the relay channel - tracing::info!( - relay = %relay_extension_name, - owner_id = %state.owner_id, - "relay OAuth callback: activating relay channel" - ); - ext_mgr - .activate_stored_relay(&relay_extension_name, &state.owner_id) - .await - .map_err(|e| format!("Failed to activate relay channel: {}", e))?; - Ok(()) } .await; diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 95cc6040099..d50c212d496 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -753,7 +753,7 @@ impl ExtensionManager { } /// Get the relay config stored at startup. - fn relay_config(&self) -> Result<&crate::config::RelayConfig, ExtensionError> { + pub(crate) fn relay_config(&self) -> Result<&crate::config::RelayConfig, ExtensionError> { self.relay_config.as_ref().ok_or_else(|| { ExtensionError::Config( "CHANNEL_RELAY_URL and CHANNEL_RELAY_API_KEY must be set".to_string(), @@ -774,7 +774,7 @@ impl ExtensionManager { /// and the URL must not contain userinfo (embedded credentials). This /// prevents a malicious override from exfiltrating the instance-wide relay /// API key to an attacker-controlled host. - async fn effective_relay_url(&self, name: &str) -> Option { + pub(crate) async fn effective_relay_url(&self, name: &str) -> Option { if let Some(ref store) = self.store { let key = format!("extensions.{name}.relay_url"); if let Ok(Some(v)) = store.get_setting(&self.user_id, &key).await {