mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
feat: multi-tenant auth with per-user workspace isolation (#1118)
* 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 * fix: scope memory tools per-user in multi-tenant mode Memory tools (search, write, read, tree) held a single workspace created at startup with GATEWAY_USER_ID. In multi-tenant mode, all users' tool calls searched the default user's scope. Add WorkspaceResolver trait that resolves workspaces per-request using JobContext.user_id. In single-user mode, returns the startup workspace. In multi-tenant mode (GATEWAY_USER_TOKENS configured), creates and caches per-user workspaces on demand. Includes regression tests for workspace resolution and user isolation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: comprehensive multi-tenant isolation audit Address all review findings from @serrrfirat plus 7 additional gaps found via full security audit: Reviewer findings (5): - WorkspacePool now applies search config, memory layers, embedding cache, identity read scopes, and global config scopes (was bare) - jobs_summary_handler uses per-user queries instead of global counters - jobs_prompt_handler restructured to not 404 agent jobs + ownership check - jobs_restart_handler agent branch now verifies user ownership - agent_job_summary_for_user added to Database trait + both backends Audit findings (7): - Delete dead handlers/memory.rs (stale copies with no auth) - Add AuthenticatedUser to logs_events, logs_level_get, logs_level_set - Add AuthenticatedUser to extensions_tools_handler, gateway_status_handler - Add auth + ownership checks to all 6 routines handlers - Add auth to all 4 skills handlers with audit logging on mutations - Scope extension setup SSE broadcast to user (broadcast_for_user) - Fix pre-existing test compilation errors in extensions/manager.rs 17 new multi-tenant isolation tests covering: - WorkspacePool config propagation and scope merging - Jobs handler per-user isolation (summary, restart, prompt, cancel) - Routines handler auth enforcement and cross-user rejection - Auth middleware enforcement on logs, skills, status endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: second-pass multi-tenant audit — scope SSE broadcasts, DB queries, dead handlers Second audit pass applying learned patterns across the codebase: - OAuth callback SSE broadcasts now use broadcast_for_user (lines 773, 912) - jobs_list_handler uses list_agent_jobs_for_user instead of fetching all users' jobs and filtering in Rust - list_agent_jobs_for_user added to Database trait + postgres + libsql - Dead handler files (extensions.rs, static_files.rs) hardened with AuthenticatedUser to prevent auth regression if migrated Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review findings — token hashing, broadcast scoping, error handling Security fixes: - Hash tokens with SHA-256 at construction time so authentication compares fixed-size 32-byte digests, eliminating length-oracle timing leaks - Scope auth SSE broadcasts per-user in chat_auth_token_handler — AuthRequired/AuthCompleted events were leaking across tenants - Propagate DB errors in restart handlers instead of silently swallowing via `if let Ok(Some(...))` pattern Code quality: - Log SSE serialization failures instead of silently producing empty strings via unwrap_or_default() - Remove dead `pub type AuthState = MultiAuthState` alias - Replace `.unwrap()` with `Arc::clone(db)` in app.rs multi-tenant workspace setup (db is guaranteed Some in context, but unwrap violates project convention) - Fix telegram setup test to inject UserIdentity into request extensions (handler now requires AuthenticatedUser) - Add safety comments on test-only expect/unwrap calls for CI - Apply cargo fmt to fix pre-existing formatting Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review findings — unify workspace pool, fix SSE regression, cache job owners - Unify WorkspacePool and PerUserWorkspaceResolver: WorkspacePool now implements WorkspaceResolver, eliminating duplicate per-user workspace construction logic. app.rs uses WorkspacePool directly. - Fix sse_tx: None scheduler regression: change scheduler/worker SSE broadcasting from broadcast::Sender<SseEvent> to Arc<SseManager>, restoring SSE event delivery for scheduled agent jobs. - Cache job owner in orchestrator: add job_owner_cache to OrchestratorState so job_event_handler avoids a DB round-trip on every event after the first per job. - Deduplicate ext_user_id computation in main.rs. - Remove unused _gateway_state variable. - Fix pre-existing test: first_token() returns None in multi-user mode by design; align test assertion. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix formatting in app.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: extract memory handlers back into handlers/memory.rs Move memory API handlers out of server.rs into their own module, consistent with how jobs, routines, and skills handlers are organized. The resolve_workspace() helper moves with them since it is only used by memory handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
+60
-15
@@ -589,15 +589,46 @@ 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;
|
||||
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 emb_cache_config = ironclaw::workspace::EmbeddingCacheConfig {
|
||||
max_entries: config.embeddings.cache_size,
|
||||
};
|
||||
let pool = Arc::new(ironclaw::channels::web::server::WorkspacePool::new(
|
||||
Arc::clone(db),
|
||||
components.embeddings.clone(),
|
||||
emb_cache_config,
|
||||
config.search.clone(),
|
||||
config.workspace.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 +679,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 +726,7 @@ 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));
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw)).await;
|
||||
}
|
||||
@@ -754,6 +789,14 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
.register_message_tools(Arc::clone(&channels), components.extension_manager.clone())
|
||||
.await;
|
||||
|
||||
// Default user ID for extension operations (single-user mode).
|
||||
let ext_user_id = config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
// Wire up channel runtime for hot-activation of WASM channels.
|
||||
if let Some(ref ext_mgr) = components.extension_manager
|
||||
&& let Some((rt, ps, router)) = wasm_channel_runtime_state.take()
|
||||
@@ -774,12 +817,14 @@ 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 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 +849,14 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
ext_mgr
|
||||
.set_relay_channel_manager(Arc::clone(&channels))
|
||||
.await;
|
||||
ext_mgr.restore_relay_channels().await;
|
||||
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(ref sse) = sse_manager
|
||||
{
|
||||
ext_mgr.set_sse_sender(sender.clone()).await;
|
||||
ext_mgr.set_sse_sender(Arc::clone(sse)).await;
|
||||
}
|
||||
|
||||
// Snapshot memory for trace recording before the agent starts
|
||||
@@ -849,7 +894,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: sse_manager,
|
||||
http_interceptor,
|
||||
transcription: config.transcription.create_provider().map(|p| {
|
||||
Arc::new(ironclaw::llm::transcription::TranscriptionMiddleware::new(
|
||||
|
||||
Reference in New Issue
Block a user