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]>
226 lines
7.0 KiB
Rust
226 lines
7.0 KiB
Rust
//! Memory/workspace API handlers.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Query, State},
|
|
http::StatusCode,
|
|
};
|
|
use serde::Deserialize;
|
|
|
|
use crate::channels::web::auth::{AuthenticatedUser, UserIdentity};
|
|
use crate::channels::web::server::GatewayState;
|
|
use crate::channels::web::types::*;
|
|
use crate::workspace::Workspace;
|
|
|
|
/// Resolve the workspace for the authenticated user.
|
|
///
|
|
/// Prefers `workspace_pool` (multi-user mode) when available, falling back
|
|
/// to the single-user `state.workspace`.
|
|
pub(crate) async fn resolve_workspace(
|
|
state: &GatewayState,
|
|
user: &UserIdentity,
|
|
) -> Result<Arc<Workspace>, (StatusCode, String)> {
|
|
if let Some(ref pool) = state.workspace_pool {
|
|
return Ok(pool.get_or_create(user).await);
|
|
}
|
|
state.workspace.as_ref().cloned().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Workspace not available".to_string(),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct TreeQuery {
|
|
#[allow(dead_code)]
|
|
pub depth: Option<usize>,
|
|
}
|
|
|
|
pub async fn memory_tree_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(user): AuthenticatedUser,
|
|
Query(_query): Query<TreeQuery>,
|
|
) -> Result<Json<MemoryTreeResponse>, (StatusCode, String)> {
|
|
let workspace = resolve_workspace(&state, &user).await?;
|
|
|
|
// Build tree from list_all (flat list of all paths)
|
|
let all_paths = workspace
|
|
.list_all()
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
// Collect unique directories and files
|
|
let mut entries: Vec<TreeEntry> = Vec::new();
|
|
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
for path in &all_paths {
|
|
// Add parent directories
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
for i in 0..parts.len().saturating_sub(1) {
|
|
let dir_path = parts[..=i].join("/");
|
|
if seen_dirs.insert(dir_path.clone()) {
|
|
entries.push(TreeEntry {
|
|
path: dir_path,
|
|
is_dir: true,
|
|
});
|
|
}
|
|
}
|
|
// Add the file itself
|
|
entries.push(TreeEntry {
|
|
path: path.clone(),
|
|
is_dir: false,
|
|
});
|
|
}
|
|
|
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
|
|
|
Ok(Json(MemoryTreeResponse { entries }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ListQuery {
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
pub async fn memory_list_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(user): AuthenticatedUser,
|
|
Query(query): Query<ListQuery>,
|
|
) -> Result<Json<MemoryListResponse>, (StatusCode, String)> {
|
|
let workspace = resolve_workspace(&state, &user).await?;
|
|
|
|
let path = query.path.as_deref().unwrap_or("");
|
|
let entries = workspace
|
|
.list(path)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let list_entries: Vec<ListEntry> = entries
|
|
.iter()
|
|
.map(|e| ListEntry {
|
|
name: e.path.rsplit('/').next().unwrap_or(&e.path).to_string(),
|
|
path: e.path.clone(),
|
|
is_dir: e.is_directory,
|
|
updated_at: e.updated_at.map(|dt| dt.to_rfc3339()),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(MemoryListResponse {
|
|
path: path.to_string(),
|
|
entries: list_entries,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ReadQuery {
|
|
pub path: String,
|
|
}
|
|
|
|
pub async fn memory_read_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(user): AuthenticatedUser,
|
|
Query(query): Query<ReadQuery>,
|
|
) -> Result<Json<MemoryReadResponse>, (StatusCode, String)> {
|
|
let workspace = resolve_workspace(&state, &user).await?;
|
|
|
|
let doc = workspace
|
|
.read(&query.path)
|
|
.await
|
|
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
|
|
|
Ok(Json(MemoryReadResponse {
|
|
path: query.path,
|
|
content: doc.content,
|
|
updated_at: Some(doc.updated_at.to_rfc3339()),
|
|
}))
|
|
}
|
|
|
|
pub async fn memory_write_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(user): AuthenticatedUser,
|
|
Json(req): Json<MemoryWriteRequest>,
|
|
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
|
|
let workspace = resolve_workspace(&state, &user).await?;
|
|
|
|
// Route through layer-aware methods when a layer is specified.
|
|
//
|
|
// Note: unlike MemoryWriteTool, this endpoint does NOT block writes to
|
|
// identity files (IDENTITY.md, SOUL.md, etc.). The HTTP API is an
|
|
// authenticated admin interface; the supervisor uses it to seed identity
|
|
// files at startup. Identity-file protection is enforced at the tool
|
|
// layer (LLM-facing) where the write originates from an untrusted agent.
|
|
if let Some(ref layer_name) = req.layer {
|
|
let result = if req.append {
|
|
workspace
|
|
.append_to_layer(layer_name, &req.path, &req.content, req.force)
|
|
.await
|
|
} else {
|
|
workspace
|
|
.write_to_layer(layer_name, &req.path, &req.content, req.force)
|
|
.await
|
|
}
|
|
.map_err(|e| {
|
|
use crate::error::WorkspaceError;
|
|
let status = match &e {
|
|
WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
|
|
WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
|
|
WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
|
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
|
};
|
|
(status, e.to_string())
|
|
})?;
|
|
return Ok(Json(MemoryWriteResponse {
|
|
path: req.path,
|
|
status: "written",
|
|
redirected: Some(result.redirected),
|
|
actual_layer: Some(result.actual_layer),
|
|
}));
|
|
}
|
|
|
|
// Non-layer path: honor the append field
|
|
if req.append {
|
|
workspace
|
|
.append(&req.path, &req.content)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
} else {
|
|
workspace
|
|
.write(&req.path, &req.content)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
}
|
|
|
|
Ok(Json(MemoryWriteResponse {
|
|
path: req.path,
|
|
status: "written",
|
|
redirected: None,
|
|
actual_layer: None,
|
|
}))
|
|
}
|
|
|
|
pub async fn memory_search_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(user): AuthenticatedUser,
|
|
Json(req): Json<MemorySearchRequest>,
|
|
) -> Result<Json<MemorySearchResponse>, (StatusCode, String)> {
|
|
let workspace = resolve_workspace(&state, &user).await?;
|
|
|
|
let limit = req.limit.unwrap_or(10);
|
|
let results = workspace
|
|
.search(&req.query, limit)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let hits: Vec<SearchHit> = results
|
|
.iter()
|
|
.map(|r| SearchHit {
|
|
path: r.document_id.to_string(),
|
|
content: r.content.clone(),
|
|
score: r.score as f64,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(MemorySearchResponse { results: hits }))
|
|
}
|