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]>
179 lines
5.3 KiB
Rust
179 lines
5.3 KiB
Rust
//! Static file and health handlers.
|
|
|
|
use axum::{
|
|
Json,
|
|
http::{StatusCode, header},
|
|
response::{Html, IntoResponse},
|
|
};
|
|
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
use crate::channels::web::auth::AuthenticatedUser;
|
|
use crate::channels::web::types::*;
|
|
|
|
// --- Static file handlers ---
|
|
|
|
pub async fn index_handler() -> Html<&'static str> {
|
|
Html(include_str!("../static/index.html"))
|
|
}
|
|
|
|
pub async fn css_handler() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "text/css")],
|
|
include_str!("../static/style.css"),
|
|
)
|
|
}
|
|
|
|
pub async fn js_handler() -> impl IntoResponse {
|
|
(
|
|
[(header::CONTENT_TYPE, "application/javascript")],
|
|
include_str!("../static/app.js"),
|
|
)
|
|
}
|
|
|
|
// --- Health ---
|
|
|
|
pub async fn health_handler() -> Json<HealthResponse> {
|
|
Json(HealthResponse {
|
|
status: "healthy",
|
|
channel: "gateway",
|
|
})
|
|
}
|
|
|
|
// --- Project file serving handlers ---
|
|
|
|
use axum::extract::Path;
|
|
|
|
/// Redirect `/projects/{id}` to `/projects/{id}/` so relative paths in
|
|
/// the served HTML resolve within the project namespace.
|
|
pub async fn project_redirect_handler(Path(project_id): Path<String>) -> impl IntoResponse {
|
|
axum::response::Redirect::permanent(&format!("/projects/{project_id}/"))
|
|
}
|
|
|
|
/// Serve `index.html` when hitting `/projects/{project_id}/`.
|
|
pub async fn project_index_handler(Path(project_id): Path<String>) -> impl IntoResponse {
|
|
serve_project_file(&project_id, "index.html").await
|
|
}
|
|
|
|
/// Serve any file under `/projects/{project_id}/{path}`.
|
|
pub async fn project_file_handler(
|
|
Path((project_id, path)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
serve_project_file(&project_id, &path).await
|
|
}
|
|
|
|
/// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`,
|
|
/// guard against path traversal, and stream the content with the right MIME type.
|
|
async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response {
|
|
// Reject project_id values that could escape the projects directory.
|
|
if project_id.contains('/')
|
|
|| project_id.contains('\\')
|
|
|| project_id.contains("..")
|
|
|| project_id.is_empty()
|
|
{
|
|
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
|
|
}
|
|
|
|
let base = ironclaw_base_dir().join("projects").join(project_id);
|
|
|
|
let file_path = base.join(path);
|
|
|
|
// Path traversal guard
|
|
let canonical = match file_path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
};
|
|
let base_canonical = match base.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
};
|
|
if !canonical.starts_with(&base_canonical) {
|
|
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
|
}
|
|
|
|
match tokio::fs::read(&canonical).await {
|
|
Ok(contents) => {
|
|
let mime = mime_guess::from_path(&canonical)
|
|
.first_or_octet_stream()
|
|
.to_string();
|
|
([(header::CONTENT_TYPE, mime)], contents).into_response()
|
|
}
|
|
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
}
|
|
}
|
|
|
|
// --- Logs ---
|
|
|
|
use std::convert::Infallible;
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::State;
|
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
use tokio_stream::StreamExt;
|
|
|
|
use crate::channels::web::server::GatewayState;
|
|
|
|
pub async fn logs_events_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(_user): AuthenticatedUser,
|
|
) -> Result<
|
|
Sse<impl futures::Stream<Item = Result<Event, Infallible>> + Send + 'static>,
|
|
(StatusCode, String),
|
|
> {
|
|
let broadcaster = state.log_broadcaster.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Log broadcaster not available".to_string(),
|
|
))?;
|
|
|
|
// Replay recent history so late-joining browsers see startup logs.
|
|
// Subscribe BEFORE snapshotting to avoid a gap between history and live.
|
|
let rx = broadcaster.subscribe();
|
|
let history = broadcaster.recent_entries();
|
|
|
|
let history_stream = futures::stream::iter(history).map(|entry| {
|
|
let data = serde_json::to_string(&entry).unwrap_or_default();
|
|
Ok(Event::default().event("log").data(data))
|
|
});
|
|
|
|
let live_stream = tokio_stream::wrappers::BroadcastStream::new(rx)
|
|
.filter_map(|result| result.ok())
|
|
.map(|entry| {
|
|
let data = serde_json::to_string(&entry).unwrap_or_default();
|
|
Ok(Event::default().event("log").data(data))
|
|
});
|
|
|
|
let stream = history_stream.chain(live_stream);
|
|
|
|
Ok(Sse::new(stream).keep_alive(
|
|
KeepAlive::new()
|
|
.interval(std::time::Duration::from_secs(30))
|
|
.text(""),
|
|
))
|
|
}
|
|
|
|
// --- Gateway status ---
|
|
|
|
pub async fn gateway_status_handler(
|
|
State(state): State<Arc<GatewayState>>,
|
|
AuthenticatedUser(_user): AuthenticatedUser,
|
|
) -> Json<GatewayStatusResponse> {
|
|
let sse_connections = state.sse.connection_count();
|
|
let ws_connections = state
|
|
.ws_tracker
|
|
.as_ref()
|
|
.map(|t| t.connection_count())
|
|
.unwrap_or(0);
|
|
|
|
Json(GatewayStatusResponse {
|
|
sse_connections,
|
|
ws_connections,
|
|
total_connections: sse_connections + ws_connections,
|
|
})
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
pub struct GatewayStatusResponse {
|
|
pub sse_connections: u64,
|
|
pub ws_connections: u64,
|
|
pub total_connections: u64,
|
|
}
|