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]>
238 lines
8.2 KiB
Rust
238 lines
8.2 KiB
Rust
//! Integration test for module-owned initialization factories.
|
|
//!
|
|
//! Verifies that the refactored factory functions in `db`, `secrets`,
|
|
//! `orchestrator`, and `extensions` modules wire up correctly end-to-end,
|
|
//! ensuring nothing was lost when initialization logic was moved out of
|
|
//! `main.rs` and `app.rs` into owning modules.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use ironclaw::db::DatabaseHandles;
|
|
use ironclaw::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build a libsql DatabaseConfig pointing at a temp file.
|
|
#[cfg(feature = "libsql")]
|
|
fn libsql_config(path: &std::path::Path) -> ironclaw::config::DatabaseConfig {
|
|
ironclaw::config::DatabaseConfig {
|
|
backend: ironclaw::config::DatabaseBackend::LibSql,
|
|
url: secrecy::SecretString::from(String::new()),
|
|
pool_size: 1,
|
|
ssl_mode: ironclaw::config::SslMode::Prefer,
|
|
libsql_path: Some(path.to_path_buf()),
|
|
libsql_url: None,
|
|
libsql_auth_token: None,
|
|
}
|
|
}
|
|
|
|
/// Build a master-key crypto instance for tests.
|
|
fn test_crypto() -> Arc<SecretsCrypto> {
|
|
let key = secrecy::SecretString::from(ironclaw::secrets::keychain::generate_master_key_hex());
|
|
Arc::new(SecretsCrypto::new(key).expect("test crypto"))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// connect_with_handles: returns Database + populated handles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "libsql")]
|
|
#[tokio::test]
|
|
async fn connect_with_handles_returns_db_and_libsql_handle() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let db_path = dir.path().join("test.db");
|
|
let config = libsql_config(&db_path);
|
|
|
|
let (db, handles) = ironclaw::db::connect_with_handles(&config)
|
|
.await
|
|
.expect("connect_with_handles");
|
|
|
|
// Database trait object works — run a trivial operation.
|
|
db.run_migrations().await.expect("migrations");
|
|
|
|
// Handle is populated.
|
|
assert!(
|
|
handles.libsql_db.is_some(),
|
|
"libsql handle should be Some after connect_with_handles"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// connect_from_config delegates to connect_with_handles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "libsql")]
|
|
#[tokio::test]
|
|
async fn connect_from_config_produces_working_db() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let db_path = dir.path().join("test.db");
|
|
let config = libsql_config(&db_path);
|
|
|
|
// connect_from_config delegates to connect_with_handles internally.
|
|
let db = ironclaw::db::connect_from_config(&config)
|
|
.await
|
|
.expect("connect_from_config");
|
|
|
|
// Verify usable — migrations should be idempotent.
|
|
db.run_migrations().await.expect("migrations");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// secrets::create_secrets_store from DatabaseHandles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "libsql")]
|
|
#[tokio::test]
|
|
async fn secrets_store_from_handles_round_trips() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let db_path = dir.path().join("test.db");
|
|
let config = libsql_config(&db_path);
|
|
|
|
let (_db, handles) = ironclaw::db::connect_with_handles(&config)
|
|
.await
|
|
.expect("connect");
|
|
|
|
let crypto = test_crypto();
|
|
let store = ironclaw::secrets::create_secrets_store(crypto, &handles)
|
|
.expect("create_secrets_store should return Some for libsql");
|
|
|
|
// Round-trip a secret to prove the store works.
|
|
store
|
|
.create("test", CreateSecretParams::new("test_key", "test_value"))
|
|
.await
|
|
.expect("create secret");
|
|
|
|
let decrypted = store
|
|
.get_decrypted("test", "test_key")
|
|
.await
|
|
.expect("get_decrypted");
|
|
assert_eq!(decrypted.expose(), "test_value");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// db::create_secrets_store (standalone CLI factory)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "libsql")]
|
|
#[tokio::test]
|
|
async fn db_create_secrets_store_standalone_round_trips() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let db_path = dir.path().join("test.db");
|
|
let config = libsql_config(&db_path);
|
|
let crypto = test_crypto();
|
|
|
|
let store = ironclaw::db::create_secrets_store(&config, crypto)
|
|
.await
|
|
.expect("db::create_secrets_store");
|
|
|
|
store
|
|
.create(
|
|
"test",
|
|
CreateSecretParams::new("standalone_key", "standalone_value"),
|
|
)
|
|
.await
|
|
.expect("create secret");
|
|
|
|
let decrypted = store
|
|
.get_decrypted("test", "standalone_key")
|
|
.await
|
|
.expect("get_decrypted");
|
|
assert_eq!(decrypted.expose(), "standalone_value");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Both secrets factories produce equivalent stores
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "libsql")]
|
|
#[tokio::test]
|
|
async fn both_secrets_factories_produce_compatible_stores() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let db_path = dir.path().join("test.db");
|
|
let config = libsql_config(&db_path);
|
|
let crypto = test_crypto();
|
|
|
|
// Factory 1: connect_with_handles + secrets::create_secrets_store
|
|
let (_db, handles) = ironclaw::db::connect_with_handles(&config)
|
|
.await
|
|
.expect("connect");
|
|
let store_a = ironclaw::secrets::create_secrets_store(Arc::clone(&crypto), &handles)
|
|
.expect("store from handles");
|
|
|
|
// Factory 2: db::create_secrets_store (standalone)
|
|
let store_b = ironclaw::db::create_secrets_store(&config, crypto)
|
|
.await
|
|
.expect("standalone store");
|
|
|
|
// Write with factory 1, read with factory 2.
|
|
store_a
|
|
.create(
|
|
"test",
|
|
CreateSecretParams::new("cross_factory", "shared_secret"),
|
|
)
|
|
.await
|
|
.expect("create via store_a");
|
|
|
|
let decrypted = store_b
|
|
.get_decrypted("test", "cross_factory")
|
|
.await
|
|
.expect("read via store_b");
|
|
assert_eq!(decrypted.expose(), "shared_secret");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ExtensionManager constructs with McpProcessManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn extension_manager_with_process_manager_constructs() {
|
|
use ironclaw::extensions::ExtensionManager;
|
|
use ironclaw::secrets::InMemorySecretsStore;
|
|
use ironclaw::tools::ToolRegistry;
|
|
use ironclaw::tools::mcp::McpProcessManager;
|
|
use ironclaw::tools::mcp::McpSessionManager;
|
|
|
|
let crypto = test_crypto();
|
|
let secrets: Arc<dyn SecretsStore + Send + Sync> = Arc::new(InMemorySecretsStore::new(crypto));
|
|
let tools = Arc::new(ToolRegistry::new());
|
|
let tools_dir = tempfile::tempdir().expect("tools_dir");
|
|
let channels_dir = tempfile::tempdir().expect("channels_dir");
|
|
|
|
let manager = ExtensionManager::new(
|
|
Arc::new(McpSessionManager::new()),
|
|
Arc::new(McpProcessManager::new()),
|
|
secrets,
|
|
tools,
|
|
None,
|
|
None,
|
|
tools_dir.path().to_path_buf(),
|
|
channels_dir.path().to_path_buf(),
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
Vec::new(),
|
|
);
|
|
|
|
// Verify the manager is functional — list returns Ok.
|
|
let result = manager.list(None, false, "test").await;
|
|
assert!(result.is_ok(), "list should succeed on empty manager");
|
|
assert!(result.unwrap().is_empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DatabaseHandles: default is empty
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn database_handles_default_is_empty() {
|
|
let handles = DatabaseHandles::default();
|
|
|
|
#[cfg(feature = "postgres")]
|
|
assert!(handles.pg_pool.is_none());
|
|
|
|
#[cfg(feature = "libsql")]
|
|
assert!(handles.libsql_db.is_none());
|
|
}
|