Files
optimclaw/tests/multi_tenant_system_prompt.rs
T
b441ebec02 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]>
2026-03-23 20:50:05 -07:00

241 lines
9.3 KiB
Rust

//! Tests proving that multi-tenant system prompts are broken.
//!
//! Bug: In multi-tenant mode, the agent loop uses `self.workspace()` which
//! returns a single shared workspace (user_id="default"). Identity files
//! (IDENTITY.md, SOUL.md, USER.md) seeded under per-user IDs ("alice",
//! "bob") are invisible to this workspace, so the system prompt is
//! empty/wrong.
//!
//! These tests:
//! 1. Seed identity files for two users (alice, bob) in the database
//! 2. Send messages as each user
//! 3. Verify the system prompt in captured LLM requests contains the
//! correct user's identity
//! 4. Verify user A's identity doesn't leak into user B's prompt
//!
//! All tests are expected to FAIL until the bug is fixed.
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use ironclaw::channels::IncomingMessage;
use ironclaw::llm::Role;
use ironclaw::workspace::Workspace;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::{LlmTrace, TraceResponse, TraceStep};
const TIMEOUT: Duration = Duration::from_secs(15);
const ALICE_USER_ID: &str = "alice";
const BOB_USER_ID: &str = "bob";
const ALICE_IDENTITY: &str = "You are Alice's personal assistant. \
Alice is a software engineer who lives in Seattle.";
const BOB_IDENTITY: &str = "You are Bob's personal assistant. \
Bob is a marine biologist who lives in Miami.";
/// Create a simple trace that returns a canned text response.
/// We need one step per message we plan to send.
fn simple_trace(num_steps: usize) -> LlmTrace {
let steps: Vec<TraceStep> = (0..num_steps)
.map(|i| TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: format!("Response {}", i),
input_tokens: 100,
output_tokens: 10,
},
expected_tool_results: Vec::new(),
})
.collect();
// Create separate turns for each step so the trace replays correctly.
let turns: Vec<crate::support::trace_llm::TraceTurn> = steps
.into_iter()
.enumerate()
.map(|(i, step)| crate::support::trace_llm::TraceTurn {
user_input: format!("message {}", i),
steps: vec![step],
expects: Default::default(),
})
.collect();
LlmTrace::new("test-model", turns)
}
/// Seed identity files for a user by creating a workspace scoped to that
/// user and writing IDENTITY.md.
async fn seed_identity(db: &Arc<dyn ironclaw::db::Database>, user_id: &str, content: &str) {
let ws = Workspace::new_with_db(user_id, db.clone());
ws.write("IDENTITY.md", content)
.await
.unwrap_or_else(|e| panic!("Failed to seed IDENTITY.md for {user_id}: {e}"));
}
/// Extract the system prompt from captured LLM requests.
///
/// The system prompt is the first message with role=System in the first
/// LLM request for a given turn.
fn extract_system_prompt(requests: &[Vec<ironclaw::llm::ChatMessage>]) -> Option<String> {
requests.last().and_then(|msgs| {
msgs.iter()
.find(|m| matches!(m.role, Role::System))
.map(|m| m.content.clone())
})
}
// -----------------------------------------------------------------------
// Test 1: Alice's identity should appear in system prompt when messaging
// as Alice.
// -----------------------------------------------------------------------
#[tokio::test]
async fn alice_system_prompt_contains_alice_identity() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
// Seed alice's identity into the database
let db = rig.database();
seed_identity(db, ALICE_USER_ID, ALICE_IDENTITY).await;
// Send a message AS alice (using her user_id)
let msg = IncomingMessage::new("test", ALICE_USER_ID, "Hello, who am I?");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
// The system prompt sent to the LLM should contain Alice's identity
let requests = rig.captured_llm_requests();
let system_prompt =
extract_system_prompt(&requests).expect("Expected a system prompt in the LLM request");
assert!(
system_prompt.contains("Alice is a software engineer"),
"System prompt should contain Alice's identity when messaging as Alice.\n\
Actual system prompt:\n{system_prompt}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 2: Bob's identity should appear in system prompt when messaging
// as Bob.
// -----------------------------------------------------------------------
#[tokio::test]
async fn bob_system_prompt_contains_bob_identity() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
// Seed bob's identity into the database
let db = rig.database();
seed_identity(db, BOB_USER_ID, BOB_IDENTITY).await;
// Send a message AS bob
let msg = IncomingMessage::new("test", BOB_USER_ID, "Hello, who am I?");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
// The system prompt should contain Bob's identity
let requests = rig.captured_llm_requests();
let system_prompt =
extract_system_prompt(&requests).expect("Expected a system prompt in the LLM request");
assert!(
system_prompt.contains("Bob is a marine biologist"),
"System prompt should contain Bob's identity when messaging as Bob.\n\
Actual system prompt:\n{system_prompt}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 3: Alice's identity must NOT appear in Bob's system prompt.
// -----------------------------------------------------------------------
#[tokio::test]
async fn alice_identity_does_not_leak_into_bob_prompt() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
// Seed BOTH users' identities
let db = rig.database();
seed_identity(db, ALICE_USER_ID, ALICE_IDENTITY).await;
seed_identity(db, BOB_USER_ID, BOB_IDENTITY).await;
// Send a message AS bob
let msg = IncomingMessage::new("test", BOB_USER_ID, "Tell me about myself");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
// Bob's prompt must NOT contain Alice's identity
let requests = rig.captured_llm_requests();
let system_prompt = extract_system_prompt(&requests);
if let Some(ref prompt) = system_prompt {
assert!(
!prompt.contains("Alice is a software engineer"),
"Alice's identity LEAKED into Bob's system prompt!\n\
System prompt:\n{prompt}"
);
}
// Also verify Bob's identity IS present (compound check)
let prompt = system_prompt.expect("Expected a system prompt in the LLM request");
assert!(
prompt.contains("Bob is a marine biologist"),
"Bob's own identity should be in his system prompt.\n\
Actual system prompt:\n{prompt}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 4: Bob's identity must NOT appear in Alice's system prompt.
// -----------------------------------------------------------------------
#[tokio::test]
async fn bob_identity_does_not_leak_into_alice_prompt() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
// Seed BOTH users' identities
let db = rig.database();
seed_identity(db, ALICE_USER_ID, ALICE_IDENTITY).await;
seed_identity(db, BOB_USER_ID, BOB_IDENTITY).await;
// Send a message AS alice
let msg = IncomingMessage::new("test", ALICE_USER_ID, "Tell me about myself");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
// Alice's prompt must NOT contain Bob's identity
let requests = rig.captured_llm_requests();
let system_prompt = extract_system_prompt(&requests);
if let Some(ref prompt) = system_prompt {
assert!(
!prompt.contains("Bob is a marine biologist"),
"Bob's identity LEAKED into Alice's system prompt!\n\
System prompt:\n{prompt}"
);
}
// Also verify Alice's identity IS present
let prompt = system_prompt.expect("Expected a system prompt in the LLM request");
assert!(
prompt.contains("Alice is a software engineer"),
"Alice's own identity should be in her system prompt.\n\
Actual system prompt:\n{prompt}"
);
rig.shutdown();
}
}