mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +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 * 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]>
126 lines
4.5 KiB
Rust
126 lines
4.5 KiB
Rust
//! Shared test utilities for gateway integration tests.
|
|
//!
|
|
//! This module is always compiled (not `#[cfg(test)]`) because integration tests
|
|
//! in `tests/` import the crate as a regular dependency and `cfg(test)` is only
|
|
//! set when compiling *this* crate's unit tests.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::channels::IncomingMessage;
|
|
use crate::channels::web::auth::MultiAuthState;
|
|
use crate::channels::web::server::{GatewayState, PerUserRateLimiter, RateLimiter, start_server};
|
|
use crate::channels::web::sse::SseManager;
|
|
use crate::channels::web::ws::WsConnectionTracker;
|
|
|
|
/// Builder for constructing a [`GatewayState`] with sensible test defaults.
|
|
///
|
|
/// Every optional field defaults to `None` and can be overridden via builder
|
|
/// methods. Call [`build`](Self::build) to get the `Arc<GatewayState>`, or
|
|
/// [`start`](Self::start) to also bind an Axum server on a random port.
|
|
pub struct TestGatewayBuilder {
|
|
msg_tx: Option<mpsc::Sender<IncomingMessage>>,
|
|
llm_provider: Option<Arc<dyn crate::llm::LlmProvider>>,
|
|
user_id: String,
|
|
}
|
|
|
|
impl Default for TestGatewayBuilder {
|
|
fn default() -> Self {
|
|
Self {
|
|
msg_tx: None,
|
|
llm_provider: None,
|
|
user_id: "test-user".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TestGatewayBuilder {
|
|
/// Create a new builder with all defaults.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Set the agent message sender (the channel the gateway forwards
|
|
/// incoming chat messages to).
|
|
pub fn msg_tx(mut self, tx: mpsc::Sender<IncomingMessage>) -> Self {
|
|
self.msg_tx = Some(tx);
|
|
self
|
|
}
|
|
|
|
/// Set the LLM provider (needed for OpenAI-compatible API tests).
|
|
pub fn llm_provider(mut self, provider: Arc<dyn crate::llm::LlmProvider>) -> Self {
|
|
self.llm_provider = Some(provider);
|
|
self
|
|
}
|
|
|
|
/// Override the user ID (default: `"test-user"`).
|
|
pub fn user_id(mut self, id: impl Into<String>) -> Self {
|
|
self.user_id = id.into();
|
|
self
|
|
}
|
|
|
|
/// Build the `Arc<GatewayState>` without starting a server.
|
|
pub fn build(self) -> Arc<GatewayState> {
|
|
Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(self.msg_tx),
|
|
sse: Arc::new(SseManager::new()),
|
|
workspace: None,
|
|
workspace_pool: None,
|
|
session_manager: None,
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: None,
|
|
tool_registry: None,
|
|
store: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
default_user_id: self.user_id,
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
|
llm_provider: self.llm_provider,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
scheduler: None,
|
|
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: RateLimiter::new(10, 60),
|
|
webhook_rate_limiter: RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
|
})
|
|
}
|
|
|
|
/// Build the state and start a gateway server on `127.0.0.1:0` (random
|
|
/// port). Returns the bound address and the shared state.
|
|
pub async fn start(
|
|
self,
|
|
auth_token: &str,
|
|
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
|
let auth = MultiAuthState::single(auth_token.to_string(), "test-user".to_string());
|
|
let state = self.build();
|
|
let addr: SocketAddr = "127.0.0.1:0"
|
|
.parse()
|
|
.expect("hard-coded address must parse"); // safety: constant literal
|
|
let bound = start_server(addr, state.clone(), auth).await?;
|
|
Ok((bound, state))
|
|
}
|
|
|
|
/// Build the state and start a gateway server with multi-user auth.
|
|
/// Returns the bound address and the shared state.
|
|
pub async fn start_multi(
|
|
self,
|
|
auth: MultiAuthState,
|
|
) -> Result<(SocketAddr, Arc<GatewayState>), crate::error::ChannelError> {
|
|
let state = self.build();
|
|
let addr: SocketAddr = "127.0.0.1:0"
|
|
.parse()
|
|
.expect("hard-coded address must parse"); // safety: constant literal
|
|
let bound = start_server(addr, state.clone(), auth).await?;
|
|
Ok((bound, state))
|
|
}
|
|
}
|