mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
feat: multi-tenant auth with per-user scoping
Multi-user authentication and authorization for IronClaw gateway: - Token-based auth mapping tokens to user IDs via GATEWAY_USER_TOKENS - Per-user SSE broadcast scoping - Per-user rate limiting with poisoned lock recovery - Handler auth and ownership checks for jobs, settings, routines - Extension secrets scoped per-user - Chat handlers use authenticated identity - Reverse proxy deployment documentation - Comprehensive integration tests for auth, SSE, rate limiting, and job isolation
This commit is contained in:
+59
-15
@@ -589,15 +589,42 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// ── Gateway channel ────────────────────────────────────────────────
|
||||
|
||||
let mut gateway_url: Option<String> = None;
|
||||
let mut sse_sender: Option<
|
||||
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
|
||||
> = None;
|
||||
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
||||
let mut _gateway_state: Option<std::sync::Arc<ironclaw::channels::web::server::GatewayState>> =
|
||||
None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw =
|
||||
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
|
||||
// Build multi-user auth state if user_tokens is configured, else single-user.
|
||||
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
|
||||
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
|
||||
let tokens = user_tokens
|
||||
.iter()
|
||||
.map(|(token, cfg)| {
|
||||
(
|
||||
token.clone(),
|
||||
UserIdentity {
|
||||
user_id: cfg.user_id.clone(),
|
||||
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let auth = MultiAuthState::multi(tokens);
|
||||
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
|
||||
} else {
|
||||
GatewayChannel::new(gw_config.clone())
|
||||
};
|
||||
gw = gw.with_llm_provider(Arc::clone(&components.llm));
|
||||
if let Some(ref ws) = components.workspace {
|
||||
gw = gw.with_workspace(Arc::clone(ws));
|
||||
}
|
||||
// Create per-user workspace pool for multi-user mode.
|
||||
if let Some(ref db) = components.db {
|
||||
let pool = Arc::new(ironclaw::channels::web::server::WorkspacePool::new(
|
||||
Arc::clone(db),
|
||||
components.embeddings.clone(),
|
||||
));
|
||||
gw = gw.with_workspace_pool(pool);
|
||||
}
|
||||
gw = gw.with_session_manager(Arc::clone(&session_manager));
|
||||
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
|
||||
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
|
||||
@@ -648,8 +675,12 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let mut rx = tx.subscribe();
|
||||
let gw_state = Arc::clone(gw.state());
|
||||
tokio::spawn(async move {
|
||||
while let Ok((_job_id, event)) = rx.recv().await {
|
||||
gw_state.sse.broadcast(event);
|
||||
while let Ok((_job_id, user_id, event)) = rx.recv().await {
|
||||
if user_id.is_empty() {
|
||||
gw_state.sse.broadcast(event);
|
||||
} else {
|
||||
gw_state.sse.broadcast_for_user(&user_id, event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -691,7 +722,8 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// Capture SSE sender and routine engine slot before moving gw into channels.
|
||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||
// creates a new SseManager, which would orphan this sender.
|
||||
sse_sender = Some(gw.state().sse.sender());
|
||||
sse_manager = Some(Arc::clone(&gw.state().sse));
|
||||
_gateway_state = Some(Arc::clone(gw.state()));
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw)).await;
|
||||
}
|
||||
@@ -774,12 +806,18 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
|
||||
// Auto-activate WASM channels that were active in a previous session.
|
||||
// Relay channels are handled separately below via restore_relay_channels().
|
||||
let persisted = ext_mgr.load_persisted_active_channels().await;
|
||||
let ext_user_id = config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let persisted = ext_mgr.load_persisted_active_channels(&ext_user_id).await;
|
||||
for name in &persisted {
|
||||
if active_at_startup.contains(name) || ext_mgr.is_relay_channel(name).await {
|
||||
if active_at_startup.contains(name) || ext_mgr.is_relay_channel(name, &ext_user_id).await {
|
||||
continue;
|
||||
}
|
||||
match ext_mgr.activate(name).await {
|
||||
match ext_mgr.activate(name, &ext_user_id).await {
|
||||
Ok(result) => {
|
||||
tracing::debug!(
|
||||
channel = %name,
|
||||
@@ -804,14 +842,20 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
ext_mgr
|
||||
.set_relay_channel_manager(Arc::clone(&channels))
|
||||
.await;
|
||||
ext_mgr.restore_relay_channels().await;
|
||||
let ext_user_id = config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
ext_mgr.restore_relay_channels(&ext_user_id).await;
|
||||
}
|
||||
|
||||
// Wire SSE sender into extension manager for broadcasting status events.
|
||||
if let Some(ref ext_mgr) = components.extension_manager
|
||||
&& let Some(ref sender) = sse_sender
|
||||
&& let Some(sse) = sse_manager
|
||||
{
|
||||
ext_mgr.set_sse_sender(sender.clone()).await;
|
||||
ext_mgr.set_sse_sender(sse).await;
|
||||
}
|
||||
|
||||
// Snapshot memory for trace recording before the agent starts
|
||||
@@ -849,7 +893,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
skills_config: config.skills.clone(),
|
||||
hooks: components.hooks,
|
||||
cost_guard: components.cost_guard,
|
||||
sse_tx: sse_sender,
|
||||
sse_tx: None, // TODO: wire SseManager into scheduler (needs Sender<SseEvent> → Arc<SseManager> refactor)
|
||||
http_interceptor,
|
||||
transcription: config.transcription.create_provider().map(|p| {
|
||||
Arc::new(ironclaw::llm::transcription::TranscriptionMiddleware::new(
|
||||
|
||||
Reference in New Issue
Block a user