Compare commits

..
Author SHA1 Message Date
Henry Park 71da7d4f1f fix(web): sanitize routine trigger errors 2026-03-27 16:52:21 -07:00
Henry Park 169ee62b08 fix(web): sanitize live gateway error responses 2026-03-27 15:47:05 -07:00
Henry ParkandClaude Opus 4.6 d8a81a0d0b fix(security): sanitize internal error details in API responses (#1702)
Replace 8 instances of `.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))`
with logging + generic error messages. DB errors (SQL details, connection info) were
being returned directly to API clients in chat history, thread listing, routine,
extension, and pairing endpoints.

Matches the existing pattern at line 1706 which already used a generic "Database error"
message. Applied the same fix to all remaining instances in server.rs.

Closes #1702

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 14:59:43 -07:00
58 changed files with 630 additions and 5978 deletions
Generated
-2
View File
@@ -3482,7 +3482,6 @@ dependencies = [
"wasmparser 0.220.1",
"wasmtime",
"wasmtime-wasi",
"webpki-roots 0.26.11",
"zbus",
"zip",
]
@@ -6992,7 +6991,6 @@ dependencies = [
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"iri-string",
"pin-project-lite",
"tower 0.5.3",
+1 -3
View File
@@ -57,7 +57,6 @@ refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
tokio-postgres-rustls = { version = "0.13", optional = true }
rustls = { version = "0.23", optional = true, default-features = false }
rustls-native-certs = { version = "0.8", optional = true }
webpki-roots = { version = "0.26", optional = true }
# Database - libSQL/Turso (optional embedded database)
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
@@ -96,7 +95,7 @@ termimad = "0.34"
# Channel integrations
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] }
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
# Cron scheduling for routines
cron = "0.13"
@@ -220,7 +219,6 @@ postgres = [
"dep:tokio-postgres-rustls",
"dep:rustls",
"dep:rustls-native-certs",
"dep:webpki-roots",
"dep:postgres-types",
"dep:refinery",
"dep:pgvector",
+8 -34
View File
@@ -1,71 +1,45 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
#
# Uses cargo-chef for dependency caching — only rebuilds deps when
# Cargo.toml/Cargo.lock change, not on every source edit.
#
# Build:
# docker build --platform linux/amd64 -t ironclaw:latest .
#
# Run:
# docker run --env-file .env -p 3000:3000 ironclaw:latest
# Stage 1: Install cargo-chef
FROM rust:1.92-slim-bookworm AS chef
# Stage 1: Build
FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-wasip2 \
&& cargo install cargo-chef wasm-tools
&& cargo install wasm-tools
WORKDIR /app
# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change)
FROM chef AS planner
# Copy manifests first for layer caching
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
# Copy source, build script, tests, and supporting directories
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
COPY benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
RUN cargo chef prepare --recipe-path recipe.json
# Stage 3: Build dependencies (cached unless Cargo.toml/lock change)
FROM chef AS deps
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Stage 4: Build the actual binary (only recompiles ironclaw source)
FROM deps AS builder
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
# [[bench]] entries in Cargo.toml require bench sources to exist for cargo to parse the manifest
COPY benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
RUN cargo build --release --bin ironclaw
# Stage 5: Runtime
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
-572
View File
@@ -1,572 +0,0 @@
# User Management API
DB-backed user management for multi-tenant IronClaw deployments. Covers admin user CRUD, per-user secrets provisioning, self-service profile, API token management, and usage reporting.
## Authentication
All endpoints require `Authorization: Bearer <token>`. Tokens are either:
- **Env-var tokens** — configured via `GATEWAY_AUTH_TOKEN` (single-user) at startup
- **DB-backed tokens** — created via `POST /api/tokens` or `POST /api/admin/users`
DB tokens are SHA-256 hashed at rest; plaintext is returned exactly once at creation time.
Auth is cached in a bounded LRU (1024 entries, 60s TTL). Suspending a user or revoking a token may take up to 60s to take effect.
## Roles
| Role | Scope |
|------|-------|
| `admin` | Full access to all endpoints |
| `member` | Self-service profile + own token management only |
Endpoints marked **Admin** return `403 Forbidden` for `member` role.
---
## Admin: Users
### POST /api/admin/users
Create a new user. Returns the user record and a one-time plaintext API token.
**Auth:** Admin
**Request body:**
```json
{
"display_name": "Alice Smith",
"email": "[email protected]",
"role": "member"
}
```
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `display_name` | string | yes | | |
| `email` | string | no | `null` | Must be unique if provided |
| `role` | string | no | `"member"` | `"admin"` or `"member"` |
**Response:** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"token": "a1b2c3d4e5f6...64-char hex...",
"created_at": "2026-03-25T12:00:00+00:00",
"created_by": "admin-user-id"
}
```
The `token` field is the plaintext API token. It is shown **only once** — store it securely.
**Errors:** `400` (missing display_name, invalid role), `403` (not admin), `503` (no database)
---
### GET /api/admin/users
List all users.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"users": [
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id"
}
]
}
```
---
### GET /api/admin/users/{id}
Get a single user by ID.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id",
"metadata": {}
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### PATCH /api/admin/users/{id}
Update a user's display name and/or metadata. Omitted fields are left unchanged.
**Auth:** Admin
**Request body:**
```json
{
"display_name": "Alice Johnson",
"metadata": {"department": "engineering"}
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `display_name` | string | no | |
| `role` | string | no | `"admin"` or `"member"` |
| `metadata` | object | no | Replaces entire metadata object (full replacement; keys not included are removed) |
**Response:** `200 OK` — returns the full updated user record (same shape as GET detail, without `last_login_at`/`created_by`).
**Errors:** `404` (user not found), `403` (not admin)
---
### POST /api/admin/users/{id}/suspend
Suspend a user. Suspended users cannot authenticate (DB auth checks user status).
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"status": "suspended"
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### POST /api/admin/users/{id}/activate
Re-activate a suspended user.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"status": "active"
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### DELETE /api/admin/users/{id}
Permanently delete a user and all associated data (tokens, jobs, conversations, memory, routines, settings, secrets).
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"deleted": true
}
```
**Errors:** `404` (user not found), `403` (not admin)
**Cascade:** Deletes from `api_tokens`, `agent_jobs`, `conversations`, `memory_documents`, `routines`, `secrets`, `settings`, `wasm_tools`, and related tables. On PostgreSQL this uses FK cascades; on libSQL it uses explicit deletes.
---
## Admin: Per-User Secrets
Provision secrets on behalf of individual users. The primary use case is an application backend (acting as admin) that configures per-user credentials so each user's IronClaw agent can call back to external services.
Secrets are encrypted at rest with AES-256-GCM using a per-secret HKDF-derived key. Plaintext values are **never returned** by any endpoint — they can only be used by the agent's tool system at runtime.
### PUT /api/admin/users/{user_id}/secrets/{name}
Create or update a secret for the specified user. If a secret with the same name already exists, it is overwritten.
**Auth:** Admin
**Path parameters:**
| Param | Type | Notes |
|-------|------|-------|
| `user_id` | string | The user's ID |
| `name` | string | Secret name (normalized to lowercase) |
**Request body:**
```json
{
"value": "sk-live-abc123...",
"provider": "my-app-backend",
"expires_in_days": 90
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `value` | string | yes | The secret value (encrypted at rest, never returned) |
| `provider` | string | no | Tag for grouping (e.g. `"stripe"`, `"my-app"`) |
| `expires_in_days` | integer | no | Auto-expire after N days; `null` = never |
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"name": "my_app_callback_token",
"status": "created"
}
```
**Errors:** `400` (missing value), `403` (not admin), `503` (secrets store not available)
**Example — application backend provisioning a callback token:**
```bash
# Admin creates a user
curl -X POST https://ironclaw.example.com/api/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"display_name": "Alice", "role": "member"}'
# Response includes: {"id": "alice-uuid", "token": "alice-bearer-token", ...}
# Admin provisions a per-user callback secret
curl -X PUT https://ironclaw.example.com/api/admin/users/alice-uuid/secrets/app_callback_token \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"value": "per-user-jwt-for-alice", "provider": "my-app"}'
# Now Alice's IronClaw agent can use the "app_callback_token" secret
# when calling tools that need to authenticate back to the app backend.
```
---
### GET /api/admin/users/{user_id}/secrets
List a user's secrets. Returns names and providers only — **never values or hashes**.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"secrets": [
{"name": "app_callback_token", "provider": "my-app"},
{"name": "openai_api_key", "provider": "openai"}
]
}
```
---
### DELETE /api/admin/users/{user_id}/secrets/{name}
Delete a specific secret for a user.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"name": "app_callback_token",
"deleted": true
}
```
**Errors:** `404` (secret not found), `403` (not admin), `503` (secrets store not available)
---
## Admin: Usage
### GET /api/admin/usage
Per-user LLM usage statistics aggregated from `llm_calls` via `agent_jobs.user_id`.
**Auth:** Admin
**Query parameters:**
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `user_id` | string | all users | Filter to a single user |
| `period` | string | `"day"` | `"day"` (24h), `"week"` (7d), or `"month"` (30d) |
**Response:** `200 OK`
```json
{
"period": "week",
"since": "2026-03-18T12:00:00+00:00",
"usage": [
{
"user_id": "alice-id",
"model": "claude-sonnet-4-5-20250514",
"call_count": 42,
"input_tokens": 150000,
"output_tokens": 30000,
"total_cost": "1.23"
}
]
}
```
---
## Self-Service: Profile
### GET /api/profile
Get the authenticated user's own profile.
**Auth:** Any authenticated user
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00"
}
```
---
### PATCH /api/profile
Update the authenticated user's own display name and/or metadata.
**Auth:** Any authenticated user
**Request body:**
```json
{
"display_name": "Alice Johnson",
"metadata": {"theme": "dark"}
}
```
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"display_name": "Alice Johnson",
"updated": true
}
```
---
## Self-Service: Tokens
### POST /api/tokens
Create a new API token for the authenticated user. Admins can optionally create tokens for other users by including `user_id`.
**Auth:** Any authenticated user
**Request body:**
```json
{
"name": "CI pipeline",
"expires_in_days": 90,
"user_id": "other-user-id"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | Human-readable label |
| `expires_in_days` | integer | no | `null` = never expires |
| `user_id` | string | no | Admin-only; create token for another user |
**Response:** `200 OK`
```json
{
"token": "a1b2c3d4...64-char hex...",
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00"
}
```
The `token` field is shown **only once**.
---
### GET /api/tokens
List the authenticated user's tokens. Token hashes are never returned.
**Auth:** Any authenticated user
**Response:** `200 OK`
```json
{
"tokens": [
{
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"last_used_at": "2026-03-25T14:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00",
"revoked_at": null
}
]
}
```
---
### DELETE /api/tokens/{id}
Revoke one of the authenticated user's tokens. Users can only revoke their own tokens.
**Auth:** Any authenticated user
**Path:** `id` — UUID of the token to revoke
**Response:** `200 OK`
```json
{
"status": "revoked",
"id": "token-uuid"
}
```
**Errors:** `400` (invalid UUID), `404` (token not found or belongs to another user)
---
## Error Format
All error responses return a plain text body with the error message and the corresponding HTTP status code:
| Code | Meaning |
|------|---------|
| `400` | Bad request (missing fields, invalid input) |
| `401` | Missing or invalid bearer token |
| `403` | Authenticated but insufficient role (member accessing admin endpoint) |
| `404` | Resource not found |
| `503` | Database or secrets store not available |
| `500` | Internal server error |
---
## Security Model
### Secrets Encryption
- **Algorithm:** AES-256-GCM with per-secret HKDF-SHA256 derived keys
- **Master key:** 32+ bytes, resolved from `SECRETS_MASTER_KEY` env var or OS keychain
- **Storage format:** `nonce (12B) || ciphertext || tag (16B)` in `encrypted_value` column
- **Per-secret salt:** 32 random bytes stored alongside the ciphertext
- **Zero-exposure:** Plaintext never appears in logs, debug output, API responses, or LLM conversations
### Auth Cache
- Bounded LRU cache (1024 entries max)
- 60-second TTL per entry
- Suspending a user or revoking a token takes up to 60s to propagate
---
## Database Schema
### users
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `TEXT` / `TEXT` | Primary key; typically UUID v4 strings (bootstrap admin may use a custom ID) |
| `email` | `TEXT UNIQUE` | Nullable |
| `display_name` | `TEXT NOT NULL` | |
| `status` | `TEXT NOT NULL` | `"active"` or `"suspended"` |
| `role` | `TEXT NOT NULL` | `"admin"` or `"member"` |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
| `last_login_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `created_by` | `TEXT` | Nullable, references `users.id` |
| `metadata` | `JSONB` / `TEXT` | Default `{}` |
### api_tokens
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key |
| `user_id` | `TEXT NOT NULL` | FK to `users.id` (PG cascades; libSQL explicit cleanup) |
| `token_hash` | `BYTEA` / `BLOB` | SHA-256 of hex-encoded plaintext |
| `token_prefix` | `TEXT NOT NULL` | First 8 chars for identification |
| `name` | `TEXT NOT NULL` | Human-readable label |
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `revoked_at` | `TIMESTAMPTZ` / `TEXT` | Nullable; set on revocation |
### secrets
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key |
| `user_id` | `TEXT NOT NULL` | Scoped to user |
| `name` | `TEXT NOT NULL` | Unique per user (lowercase normalized) |
| `encrypted_value` | `BYTEA` / `BLOB` | AES-256-GCM (nonce + ciphertext + tag) |
| `key_salt` | `BYTEA` / `BLOB` | Per-secret HKDF salt |
| `provider` | `TEXT` | Optional grouping tag |
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Audit: last injection time |
| `usage_count` | `BIGINT` / `INTEGER` | Audit: total injections |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
-31
View File
@@ -1,31 +0,0 @@
-- User management tables for multi-tenant deployments.
--
-- Replaces the static GATEWAY_USER_TOKENS env var with DB-backed
-- user registration, API token management, and invitation flow.
CREATE TABLE users (
id TEXT PRIMARY KEY, -- matches existing user_id pattern (string, not UUID)
email TEXT UNIQUE, -- nullable for token-only users
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | suspended | deactivated
role TEXT NOT NULL DEFAULT 'member', -- admin | member
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ,
created_by TEXT REFERENCES users(id), -- who invited this user (nullable for bootstrap)
metadata JSONB NOT NULL DEFAULT '{}' -- extensible profile data
);
CREATE TABLE api_tokens (
id UUID PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL, -- SHA-256 hash (never store plaintext)
token_prefix TEXT NOT NULL, -- first 8 hex chars for display
name TEXT NOT NULL, -- human label ("my-laptop", "ci-bot")
expires_at TIMESTAMPTZ, -- nullable = never expires
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ -- soft-revoke: set this instead of deleting
);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
+5 -45
View File
@@ -345,25 +345,13 @@ impl Agent {
.map(|db| crate::tenant::TenantScope::new(user_id, Arc::clone(db)));
// Reuse the owner workspace if user matches, otherwise create per-user.
// Per-user workspaces are seeded on first creation so they get identity
// files and BOOTSTRAP.md (which triggers the onboarding greeting).
let workspace = match &self.deps.workspace {
Some(ws) if ws.user_id() == user_id => Some(Arc::clone(ws)),
_ => {
if let Some(db) = self.deps.store.as_ref() {
let ws = Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)));
if let Err(e) = ws.seed_if_empty().await {
tracing::warn!(
user_id = user_id,
"Failed to seed per-user workspace: {}",
e
);
}
Some(ws)
} else {
None
}
}
_ => self
.deps
.store
.as_ref()
.map(|db| Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)))),
};
crate::tenant::TenantCtx::new(
@@ -1275,34 +1263,6 @@ impl Agent {
// Build per-tenant execution context once; threaded through all handlers.
let tenant = self.tenant_ctx(&message.user_id).await;
// Per-user bootstrap: if this user's workspace was just seeded (fresh),
// persist the static greeting to their assistant conversation and
// broadcast it so the web client shows it immediately.
if tenant
.workspace()
.is_some_and(|ws| ws.take_bootstrap_pending())
{
tracing::info!(
user_id = message.user_id,
"Fresh user workspace — persisting bootstrap greeting"
);
if let Some(store) = tenant.store()
&& let Ok(conv_id) = store
.get_or_create_assistant_conversation(&message.channel)
.await
{
let _ = store
.add_conversation_message(conv_id, "assistant", BOOTSTRAP_GREETING)
.await;
let mut out = OutgoingResponse::text(BOOTSTRAP_GREETING.to_string());
out.thread_id = Some(conv_id.to_string());
let _ = self
.channels
.broadcast(&message.channel, &message.user_id, out)
.await;
}
}
let session_for_empty_exit = Arc::clone(&session);
// Process based on submission type
+9 -35
View File
@@ -400,21 +400,16 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
};
// Record cost and track token usage (global + per-user).
// Use the provider's effective_model_name so cost attribution matches
// the model that actually served the request. When the override is
// honoured (e.g. NearAI), this returns the override name; when the
// provider ignores overrides (e.g. Rig-based), it returns the active
// model, keeping attribution accurate in both cases.
let model_name = self
.agent
.llm()
.effective_model_name(reason_ctx.model_override.as_deref());
let cost_per_token = if reason_ctx.model_override.is_some() {
// Override may use different pricing; let CostGuard fall back to
// costs::model_cost() for the effective model.
None
// When a model override is active, use the override name for attribution
// and let CostGuard look up pricing via costs::model_cost() instead of
// using the default provider's cost_per_token (which reflects the wrong model).
let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
(ovr.clone(), None)
} else {
Some(self.agent.llm().cost_per_token())
(
self.agent.llm().active_model_name(),
Some(self.agent.llm().cost_per_token()),
)
};
let read_discount = self.agent.llm().cache_read_discount();
let write_multiplier = self.agent.llm().cache_write_multiplier();
@@ -438,24 +433,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
call_cost,
);
// Persist LLM call to DB so usage stats survive restarts.
// Chat turns don't create agent_jobs, so job_id is None.
if let Some(store) = self.tenant.store() {
let record = crate::history::LlmCallRecord {
job_id: None,
conversation_id: Some(self.thread_id),
provider: &self.agent.deps.llm_backend,
model: &model_name,
input_tokens: output.usage.input_tokens,
output_tokens: output.usage.output_tokens,
cost: call_cost,
purpose: Some("chat"),
};
if let Err(e) = store.record_llm_call(&record).await {
tracing::warn!("Failed to persist LLM call to DB: {}", e);
}
}
Ok(output)
}
@@ -1383,7 +1360,6 @@ mod tests {
max_tool_iterations: 50,
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -2265,7 +2241,6 @@ mod tests {
max_tool_iterations,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -2393,7 +2368,6 @@ mod tests {
max_tool_iterations: max_iter,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
+23 -25
View File
@@ -400,7 +400,7 @@ impl HeartbeatRunner {
}
/// Send a notification about heartbeat findings.
async fn send_notification(&self, message: &str) {
pub(crate) async fn send_notification(&self, message: &str) {
let Some(ref tx) = self.response_tx else {
tracing::debug!("No response channel configured for heartbeat notifications");
return;
@@ -512,8 +512,8 @@ pub fn spawn_heartbeat(
})
}
/// Spawn a multi-user heartbeat runner that cycles through all users who
/// have routines (enabled or not). Each tick, it queries the DB for distinct
/// Spawn a multi-user heartbeat runner that cycles through all users that
/// own routines (enabled or not). Each tick, it queries the DB for distinct
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
/// each user concurrently. Per-user failure counts are tracked independently.
pub fn spawn_multi_user_heartbeat(
@@ -574,10 +574,8 @@ pub fn spawn_multi_user_heartbeat(
}
};
// Run user heartbeats (and hygiene) concurrently so one slow LLM
// call doesn't block others. Cap concurrency to avoid flooding the
// LLM provider. Hygiene runs inside the same JoinSet so it is
// tracked and bounded by the same concurrency cap.
// Run user heartbeats concurrently so one slow LLM call doesn't
// block others. Cap concurrency to avoid flooding the LLM provider.
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
let mut join_set = tokio::task::JoinSet::new();
@@ -590,6 +588,23 @@ pub fn spawn_multi_user_heartbeat(
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db())));
// Run memory hygiene per user (same as single-user heartbeat).
let hygiene_ws = Arc::clone(&workspace);
let hygiene_cfg = hygiene_config.clone();
let hygiene_user = user_id.clone();
tokio::spawn(async move {
let report =
crate::workspace::hygiene::run_if_due(&hygiene_ws, &hygiene_cfg).await;
if report.had_work() {
tracing::info!(
user_id = hygiene_user,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
});
// Drain completed tasks to stay within the concurrency cap.
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
if let Some(join_result) = join_set.join_next().await {
@@ -598,30 +613,13 @@ pub fn spawn_multi_user_heartbeat(
}
let uid = user_id.clone();
// In multi-tenant mode, clear notify_user_id so that
// HeartbeatRunner::send_notification falls back to
// workspace.user_id() — each user's heartbeat should persist
// and notify that user, not the shared config target.
let mut cfg = config.clone();
cfg.notify_user_id = None;
let cfg = config.clone();
let hyg = hygiene_config.clone();
let llm_clone = llm.clone();
let tx = response_tx.clone();
let admin = store.clone();
join_set.spawn(async move {
// Run memory hygiene per user (same as single-user heartbeat)
// inside the tracked task so concurrency is bounded.
let report = crate::workspace::hygiene::run_if_due(&workspace, &hyg).await;
if report.had_work() {
tracing::info!(
user_id = uid,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
if let Some(tx) = tx {
runner = runner.with_response_channel(tx);
-15
View File
@@ -267,20 +267,6 @@ impl Scheduler {
});
}
// Per-user concurrency check — only count jobs consuming a parallel
// execution slot (Pending/InProgress/Stuck), not Completed/Submitted.
if let Some(max_per_user) = self.config.max_jobs_per_user
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
{
let user_blocking = self
.context_manager
.parallel_blocking_count_for(&ctx.user_id)
.await;
if user_blocking >= max_per_user {
return Err(JobError::MaxJobsExceeded { max: max_per_user });
}
}
// Transition job to in_progress
self.context_manager
.update_context(job_id, |ctx| {
@@ -798,7 +784,6 @@ mod tests {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job,
multi_tenant: false,
max_llm_concurrent_per_user: None,
+7 -2
View File
@@ -336,12 +336,17 @@ impl AppBuilder {
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
// Detect multi-tenant mode: when the database has registered users,
// Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
// each authenticated user needs their own workspace scope. Use
// WorkspacePool (which implements WorkspaceResolver) to create
// per-user workspaces on demand instead of sharing the startup
// workspace across all users.
let is_multi_tenant = db.has_any_users().await.unwrap_or(false);
let is_multi_tenant = self
.config
.channels
.gateway
.as_ref()
.is_some_and(|gw| gw.user_tokens.is_some());
if is_multi_tenant {
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
-28
View File
@@ -91,34 +91,6 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| DELETE | `/api/routines/{id}` | Delete a routine |
| GET | `/api/routines/{id}/runs` | List runs for a specific routine |
### User Management (admin — requires `admin` role, see `docs/USER_MANAGEMENT_API.md`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/admin/users` | Create a new user (returns one-time token) |
| GET | `/api/admin/users` | List all users |
| GET | `/api/admin/users/{id}` | Get a single user |
| PATCH | `/api/admin/users/{id}` | Update user profile/metadata |
| DELETE | `/api/admin/users/{id}` | Delete user and all data |
| POST | `/api/admin/users/{id}/suspend` | Suspend a user |
| POST | `/api/admin/users/{id}/activate` | Re-activate a user |
| GET | `/api/admin/usage` | Per-user LLM usage stats |
| GET | `/api/admin/users/{user_id}/secrets` | List a user's secrets (names only) |
| PUT | `/api/admin/users/{user_id}/secrets/{name}` | Create or update a user's secret |
| DELETE | `/api/admin/users/{user_id}/secrets/{name}` | Delete a user's secret |
### Profile (self-service)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/profile` | Get own profile |
| PATCH | `/api/profile` | Update own display name/metadata |
### Tokens (self-service)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tokens` | Create API token (returns plaintext once) |
| GET | `/api/tokens` | List own tokens |
| DELETE | `/api/tokens/{id}` | Revoke a token |
### Settings
| Method | Path | Description |
|--------|------|-------------|
+20 -220
View File
@@ -5,7 +5,6 @@
//! handlers can extract it via `AuthenticatedUser`.
use std::collections::HashMap;
use std::num::NonZeroUsize;
use axum::{
extract::{FromRequestParts, Request, State},
@@ -14,25 +13,18 @@ use axum::{
response::{IntoResponse, Response},
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::Instant;
use subtle::ConstantTimeEq;
use tokio::sync::RwLock;
use crate::db::Database;
/// Identity resolved from a bearer token.
#[derive(Debug, Clone)]
pub struct UserIdentity {
pub user_id: String,
/// `admin` or `member`.
pub role: String,
/// Additional user scopes this identity can read from.
pub workspace_read_scopes: Vec<String>,
}
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
pub fn hash_token(token: &str) -> [u8; 32] {
fn hash_token(token: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.finalize().into()
@@ -64,7 +56,6 @@ impl MultiAuthState {
hash,
UserIdentity {
user_id,
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
)],
@@ -73,11 +64,6 @@ impl MultiAuthState {
}
/// Create a multi-user auth state from a map of tokens to identities.
///
/// **Test-only** — production multi-user auth is DB-backed via
/// `DbAuthenticator`. This constructor is kept public (not `#[cfg(test)]`)
/// because integration tests in `tests/` compile the crate as a library
/// where `cfg(test)` is not set.
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
.into_iter()
@@ -122,131 +108,6 @@ impl MultiAuthState {
}
}
/// DB-backed token authenticator with a bounded LRU cache.
///
/// Checks an LRU cache first (TTL 60s), then falls back to a DB query.
/// The cache is bounded to `MAX_CACHE_ENTRIES` — when full, the least
/// recently used entry is evicted regardless of TTL.
///
/// Revoking a token or suspending a user has at most 60s of stale
/// authentication before the cache entry expires.
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct DbAuthenticator {
store: Arc<dyn Database>,
/// Bounded LRU cache: token_hash → (identity, inserted_at).
cache: Arc<RwLock<lru::LruCache<[u8; 32], (UserIdentity, Instant)>>>,
}
impl DbAuthenticator {
/// Cache TTL — how long a successful auth is cached before re-querying the DB.
const CACHE_TTL_SECS: u64 = 60;
/// Maximum cache entries to prevent unbounded growth.
// SAFETY: 1024 is non-zero, so the unwrap in `new()` is infallible.
const MAX_CACHE_ENTRIES: NonZeroUsize = match NonZeroUsize::new(1024) {
Some(v) => v,
None => unreachable!(),
};
pub fn new(store: Arc<dyn Database>) -> Self {
Self {
store,
cache: Arc::new(RwLock::new(lru::LruCache::new(Self::MAX_CACHE_ENTRIES))),
}
}
/// Evict all cached entries for a specific user.
///
/// Call this after security-critical actions (suspend, activate, role
/// change, token revocation) so the change takes effect immediately
/// instead of waiting for the 60-second TTL to expire.
pub async fn invalidate_user(&self, user_id: &str) {
let mut cache = self.cache.write().await;
// LruCache doesn't support predicate-based removal, so collect keys
// first then remove. The cache is bounded (1024) so this is cheap.
let keys_to_remove: Vec<[u8; 32]> = cache
.iter()
.filter(|(_, (identity, _))| identity.user_id == user_id)
.map(|(k, _)| *k)
.collect();
for key in keys_to_remove {
cache.pop(&key);
}
}
/// Authenticate a token against the database, using cache when possible.
///
/// Returns `Ok(Some(identity))` on success, `Ok(None)` if the token is
/// not found, or `Err(())` if the database is unreachable (so the caller
/// can return 503 instead of 401).
pub async fn authenticate(&self, candidate: &str) -> Result<Option<UserIdentity>, ()> {
let hash = hash_token(candidate);
// Check cache first (promotes to most-recent on hit)
{
let mut cache = self.cache.write().await;
if let Some((identity, inserted_at)) = cache.get(&hash) {
if inserted_at.elapsed().as_secs() < Self::CACHE_TTL_SECS {
return Ok(Some(identity.clone()));
}
// Expired — remove stale entry
cache.pop(&hash);
}
}
// Cache miss or expired — query DB
let (token_record, user_record) = match self.store.authenticate_token(&hash).await {
Ok(Some(pair)) => pair,
Ok(None) => return Ok(None),
Err(e) => {
tracing::warn!("DB auth lookup failed: {e}");
return Err(());
}
};
let identity = UserIdentity {
user_id: user_record.id.clone(),
role: user_record.role.clone(),
workspace_read_scopes: Vec::new(),
};
// Record token usage (best-effort, don't block auth)
let store = self.store.clone();
let token_id = token_record.id;
let user_id = user_record.id;
tokio::spawn(async move {
let _ = store.record_token_usage(token_id).await;
let _ = store.record_login(&user_id).await;
});
// Insert into bounded LRU — if full, least-recently-used entry is evicted
{
let mut cache = self.cache.write().await;
cache.put(hash, (identity.clone(), Instant::now()));
}
Ok(Some(identity))
}
}
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
#[derive(Clone)]
pub struct CombinedAuthState {
/// In-memory tokens from GATEWAY_AUTH_TOKEN.
pub env_auth: MultiAuthState,
/// DB-backed token authenticator (optional — only when a database is available).
pub db_auth: Option<DbAuthenticator>,
}
impl From<MultiAuthState> for CombinedAuthState {
fn from(env_auth: MultiAuthState) -> Self {
Self {
env_auth,
db_auth: None,
}
}
}
/// Axum extractor that provides the authenticated user identity.
///
/// Only available on routes behind `auth_middleware`. Extracts the
@@ -269,31 +130,6 @@ where
}
}
/// Axum extractor that requires the authenticated user to have the `admin` role.
///
/// Use instead of `AuthenticatedUser` on endpoints that modify system-wide
/// state (user management, model selection, extension/skill installation).
pub struct AdminUser(pub UserIdentity);
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let identity = parts
.extensions
.get::<UserIdentity>()
.cloned()
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
if identity.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin role required"));
}
Ok(AdminUser(identity))
}
}
/// Whether query-string token auth is allowed for this request.
///
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
@@ -330,65 +166,39 @@ fn query_token(request: &Request) -> Option<String> {
/// Auth middleware that validates bearer token from header or query param.
///
/// Tries env-var tokens first (constant-time, in-memory), then falls back
/// to DB-backed token lookup if configured. SSE connections can't set
/// headers from `EventSource`, so we also accept `?token=xxx` as a query
/// parameter, but only on SSE/WS endpoints.
/// SSE connections can't set headers from `EventSource`, so we also accept
/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
///
/// On successful authentication, inserts the matching `UserIdentity` into
/// request extensions for downstream extraction via `AuthenticatedUser`.
pub async fn auth_middleware(
State(auth): State<CombinedAuthState>,
State(auth): State<MultiAuthState>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Response {
// Extract the candidate token from header or query param.
let token = extract_token(&headers, &request);
if let Some(ref tok) = token {
// 1. Try env-var tokens first (fast, constant-time, in-memory).
if let Some(identity) = auth.env_auth.authenticate(tok) {
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
// 2. Fall back to DB-backed token lookup.
if let Some(ref db_auth) = auth.db_auth {
match db_auth.authenticate(tok).await {
Ok(Some(identity)) => {
request.extensions_mut().insert(identity);
return next.run(request).await;
}
Err(()) => {
return (StatusCode::SERVICE_UNAVAILABLE, "Database unavailable")
.into_response();
}
Ok(None) => {}
}
}
}
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
}
/// Extract a bearer token from the Authorization header or query parameter.
fn extract_token(headers: &HeaderMap, request: &Request) -> Option<String> {
// Try Authorization header first (RFC 6750).
// Try Authorization header first.
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
if let Some(auth_header) = headers.get("authorization")
&& let Ok(value) = auth_header.to_str()
&& value.len() > 7
&& value[..7].eq_ignore_ascii_case("Bearer ")
&& let Some(identity) = auth.authenticate(&value[7..])
{
return Some(value[7..].to_string());
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
// Fall back to query parameter for SSE/WS endpoints.
if allows_query_token_auth(request) {
return query_token(request);
// Fall back to query parameter, but only for SSE/WS endpoints.
if allows_query_token_auth(&request)
&& let Some(token) = query_token(&request)
&& let Some(identity) = auth.authenticate(&token)
{
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
None
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
}
#[cfg(test)]
@@ -417,7 +227,6 @@ mod tests {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -425,7 +234,6 @@ mod tests {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -466,10 +274,7 @@ mod tests {
/// Router with streaming endpoints (query auth allowed) and regular
/// endpoints (query auth rejected).
fn test_app(token: &str) -> Router {
let state = CombinedAuthState::from(MultiAuthState::single(
token.to_string(),
"test-user".to_string(),
));
let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
Router::new()
.route("/api/chat/events", get(dummy_handler))
.route("/api/logs/events", get(dummy_handler))
@@ -681,7 +486,7 @@ mod tests {
/// Build a multi-user router where each token maps to a distinct identity.
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
let state = CombinedAuthState::from(MultiAuthState::multi(tokens));
let state = MultiAuthState::multi(tokens);
Router::new()
.route("/api/chat/events", get(identity_handler))
.route("/api/chat/send", post(identity_handler))
@@ -695,7 +500,6 @@ mod tests {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -703,7 +507,6 @@ mod tests {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
},
);
@@ -840,10 +643,7 @@ mod tests {
#[tokio::test]
async fn test_multi_user_empty_scopes_for_single_user() {
// Single-user mode creates identity with empty workspace_read_scopes.
let state = CombinedAuthState::from(MultiAuthState::single(
"tok-only".to_string(),
"solo".to_string(),
));
let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
let app = Router::new()
.route("/api/scopes", get(scopes_handler))
.layer(middleware::from_fn_with_state(state, auth_middleware));
+23 -46
View File
@@ -14,14 +14,7 @@ use uuid::Uuid;
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
fn db_error(context: &str, e: impl std::fmt::Display) -> (StatusCode, String) {
tracing::error!(%e, context, "Database error in jobs handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal database error".to_string(),
)
}
use crate::channels::web::util::{sanitized_db_error, sanitized_internal_error_response};
pub async fn jobs_list_handler(
State(state): State<Arc<GatewayState>>,
@@ -221,7 +214,7 @@ pub async fn jobs_detail_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err(sanitized_db_error(e, "get sandbox job detail"));
}
}
@@ -262,7 +255,7 @@ pub async fn jobs_detail_handler(
}))
}
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
Err(e) => Err(db_error("jobs_handler", e)),
Err(e) => Err(sanitized_db_error(e, "get agent job detail")),
}
}
@@ -297,7 +290,7 @@ pub async fn jobs_cancel_handler(
Some(chrono::Utc::now()),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "persist sandbox job cancellation"))?;
}
return Ok(Json(serde_json::json!({
"status": "cancelled",
@@ -306,7 +299,7 @@ pub async fn jobs_cancel_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err(sanitized_db_error(e, "get sandbox job for cancellation"));
}
}
}
@@ -340,7 +333,7 @@ pub async fn jobs_cancel_handler(
Some("Cancelled by user"),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "persist agent job cancellation"))?;
}
return Ok(Json(serde_json::json!({
"status": "cancelled",
@@ -349,7 +342,7 @@ pub async fn jobs_cancel_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err(sanitized_db_error(e, "get agent job for cancellation"));
}
}
}
@@ -417,7 +410,7 @@ pub async fn jobs_restart_handler(
store
.save_sandbox_job(&record)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "persist restarted sandbox job"))?;
let mode = match store.get_sandbox_job_mode(old_job_id).await {
Ok(Some(m)) if m == "claude_code" => {
@@ -448,16 +441,13 @@ pub async fn jobs_restart_handler(
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create container: {}", e),
)
sanitized_internal_error_response(e, "create restarted sandbox container")
})?;
store
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "mark restarted sandbox job running"))?;
return Ok(Json(serde_json::json!({
"status": "restarted",
@@ -467,7 +457,7 @@ pub async fn jobs_restart_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err(sanitized_db_error(e, "get sandbox job for restart"));
}
}
@@ -514,7 +504,9 @@ pub async fn jobs_restart_handler(
let new_job_id = scheduler
.dispatch_job(&old_job.user_id, &title, &old_job.description, None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
sanitized_internal_error_response(e, "dispatch restarted agent job")
})?;
Ok(Json(serde_json::json!({
"status": "restarted",
@@ -523,7 +515,7 @@ pub async fn jobs_restart_handler(
})))
}
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
Err(e) => Err(db_error("jobs_handler", e)),
Err(e) => Err(sanitized_db_error(e, "get agent job for restart")),
}
}
@@ -599,7 +591,7 @@ pub async fn jobs_prompt_handler(
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err(sanitized_db_error(e, "get agent job for prompt"));
}
}
}
@@ -612,10 +604,9 @@ pub async fn jobs_prompt_handler(
if let Some(ref scheduler) = *scheduler_guard
&& scheduler.is_running(job_id).await
{
scheduler
.send_message(job_id, content)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
scheduler.send_message(job_id, content).await.map_err(|e| {
sanitized_internal_error_response(e, "send prompt to running agent job")
})?;
return Ok(Json(serde_json::json!({
"status": "sent",
"job_id": job_id.to_string(),
@@ -654,14 +645,14 @@ pub async fn jobs_events_handler(
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err(sanitized_db_error(e, "get sandbox job events"));
}
}
let events = store
.list_job_events(job_id, None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "list job events"))?;
let events_json: Vec<serde_json::Value> = events
.into_iter()
@@ -705,7 +696,7 @@ pub async fn job_files_list_handler(
let job = store
.get_sandbox_job(job_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get sandbox job file list"))?
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
if job.user_id != user.user_id {
@@ -773,7 +764,7 @@ pub async fn job_files_read_handler(
let job = store
.get_sandbox_job(job_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get sandbox job file read"))?
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
if job.user_id != user.user_id {
@@ -807,17 +798,3 @@ pub async fn job_files_read_handler(
content,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_db_error_does_not_leak_details() {
let (status, body) = db_error("test_context", "relation \"jobs\" does not exist");
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body, "Internal database error");
assert!(!body.contains("relation"));
assert!(!body.contains("does not exist"));
}
}
-3
View File
@@ -5,10 +5,7 @@
pub mod jobs;
pub mod memory;
pub mod routines;
pub mod secrets;
pub mod skills;
pub mod tokens;
pub mod users;
// Modules not yet wired into server.rs router -- suppress dead_code until
// they replace their inline counterparts.
+12 -24
View File
@@ -14,7 +14,7 @@ use crate::agent::routine::{Trigger, next_cron_fire};
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
use crate::error::RoutineError;
use crate::channels::web::util::{sanitized_db_error, sanitized_routine_error};
pub async fn routines_list_handler(
State(state): State<Arc<GatewayState>>,
@@ -28,7 +28,7 @@ pub async fn routines_list_handler(
let routines = store
.list_routines(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "list routines"))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
@@ -47,7 +47,7 @@ pub async fn routines_summary_handler(
let routines = store
.list_routines(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "list routines summary"))?;
let total = routines.len() as u64;
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
@@ -95,7 +95,7 @@ pub async fn routines_detail_handler(
let routine = store
.get_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get routine detail"))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
if routine.user_id != user.user_id {
@@ -105,7 +105,7 @@ pub async fn routines_detail_handler(
let runs = store
.list_routine_runs(routine_id, 20)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "list routine detail runs"))?;
let recent_runs: Vec<RoutineRunInfo> = runs
.iter()
@@ -163,7 +163,7 @@ pub async fn routines_trigger_handler(
let run_id = engine
.fire_manual(routine_id, Some(&user.user_id))
.await
.map_err(|e| (routine_error_status(&e), e.to_string()))?;
.map_err(|e| sanitized_routine_error(e, "trigger routine manually"))?;
Ok(Json(serde_json::json!({
"status": "triggered",
@@ -194,7 +194,7 @@ pub async fn routines_toggle_handler(
let mut routine = store
.get_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get routine for toggle"))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
if routine.user_id != user.user_id {
@@ -228,7 +228,7 @@ pub async fn routines_toggle_handler(
store
.update_routine(&routine)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "update routine toggle state"))?;
// Refresh the in-memory event trigger cache so event/system_event
// routines reflect the new enabled state immediately (issue #1076).
@@ -259,7 +259,7 @@ pub async fn routines_delete_handler(
let routine = store
.get_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get routine for delete"))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
if routine.user_id != user.user_id {
@@ -269,7 +269,7 @@ pub async fn routines_delete_handler(
let deleted = store
.delete_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "delete routine"))?;
if deleted {
// Refresh the in-memory event trigger cache so deleted event/system_event
@@ -305,7 +305,7 @@ pub async fn routines_runs_handler(
let routine = store
.get_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get routine runs"))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
if routine.user_id != user.user_id {
@@ -315,7 +315,7 @@ pub async fn routines_runs_handler(
let runs = store
.list_routine_runs(routine_id, 50)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| sanitized_db_error(e, "list routine runs"))?;
let run_infos: Vec<RoutineRunInfo> = runs
.iter()
@@ -336,15 +336,3 @@ pub async fn routines_runs_handler(
"runs": run_infos,
})))
}
/// Map `RoutineError` variants to appropriate HTTP status codes.
fn routine_error_status(err: &RoutineError) -> StatusCode {
match err {
RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
RoutineError::Disabled { .. }
| RoutineError::Cooldown { .. }
| RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
-183
View File
@@ -1,183 +0,0 @@
//! Admin secrets provisioning handlers.
//!
//! Allows an admin (typically an application backend) to create, list, and
//! delete secrets on behalf of individual users so their IronClaw agent can
//! call back to external services with per-user credentials.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use crate::channels::web::auth::AdminUser;
use crate::channels::web::server::GatewayState;
use crate::secrets::CreateSecretParams;
/// PUT /api/admin/users/{user_id}/secrets/{name} — create or update a secret.
///
/// Upserts: if a secret with the same (user_id, name) already exists it is
/// overwritten. The plaintext value is encrypted at rest (AES-256-GCM) and
/// never returned by any endpoint.
pub async fn secrets_put_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path((user_id, name)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let name = name.to_lowercase();
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
store
.get_user(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let value = body
.get("value")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing required field 'value'".to_string(),
))?
.to_string();
let provider = body
.get("provider")
.and_then(|v| v.as_str())
.map(String::from);
let expires_in_days = body.get("expires_in_days").and_then(|v| v.as_u64());
if let Some(days) = expires_in_days
&& days > 36500
{
return Err((
StatusCode::BAD_REQUEST,
"expires_in_days must be at most 36500".to_string(),
));
}
let expires_at =
expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days as i64));
let mut params = CreateSecretParams::new(name.clone(), value);
if let Some(p) = provider {
params = params.with_provider(p);
}
if let Some(exp) = expires_at {
params = params.with_expiry(exp);
}
let already_exists = secrets
.exists(&user_id, &name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
secrets
.create(&user_id, params)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"user_id": user_id,
"name": name,
"status": if already_exists { "updated" } else { "created" },
})))
}
/// GET /api/admin/users/{user_id}/secrets — list a user's secrets (names only).
///
/// Never returns secret values or hashes.
pub async fn secrets_list_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path(user_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Verify the target user exists (consistent with PUT/DELETE).
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
if store
.get_user(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.is_none()
{
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
}
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let refs = secrets
.list(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let secrets_json: Vec<serde_json::Value> = refs
.into_iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"provider": r.provider,
})
})
.collect();
Ok(Json(serde_json::json!({
"user_id": user_id,
"secrets": secrets_json,
})))
}
/// DELETE /api/admin/users/{user_id}/secrets/{name} — delete a user's secret.
pub async fn secrets_delete_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path((user_id, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let name = name.to_lowercase();
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
store
.get_user(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let deleted = secrets
.delete(&user_id, &name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !deleted {
return Err((StatusCode::NOT_FOUND, "Secret not found".to_string()));
}
Ok(Json(serde_json::json!({
"user_id": user_id,
"name": name,
"deleted": true,
})))
}
-163
View File
@@ -1,163 +0,0 @@
//! API token management handlers.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use rand::RngCore;
use rand::rngs::OsRng;
use uuid::Uuid;
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
/// POST /api/tokens — create a new API token (returns plaintext ONCE).
pub async fn tokens_create_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let name = body
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing or empty 'name'".to_string(),
))?
.to_string();
let expires_in_days: Option<i64> = match body.get("expires_in_days").and_then(|v| v.as_u64()) {
Some(d) if d > 36500 => {
return Err((
StatusCode::BAD_REQUEST,
"expires_in_days must not exceed 36500 (100 years)".to_string(),
));
}
Some(d) => Some(d as i64),
None => None,
};
let expires_at = expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days));
// Generate 32 random bytes for the token.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
// First 8 chars of the hex token as a prefix for identification.
let token_prefix = &plaintext_token[..8];
// Admin users can create tokens for other users via optional "user_id" field.
let target_user = body
.get("user_id")
.and_then(|v| v.as_str())
.filter(|_| user.role == "admin")
.unwrap_or(&user.user_id);
// Verify the target user exists to prevent orphan tokens.
if target_user != user.user_id {
store
.get_user(target_user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::NOT_FOUND,
format!("Target user '{target_user}' not found"),
))?;
}
let record = store
.create_api_token(target_user, &name, &hash, token_prefix, expires_at)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Return the plaintext token — this is the ONLY time it is shown.
Ok(Json(serde_json::json!({
"token": plaintext_token,
"id": record.id.to_string(),
"name": record.name,
"token_prefix": record.token_prefix,
"expires_at": record.expires_at.map(|dt| dt.to_rfc3339()),
"created_at": record.created_at.to_rfc3339(),
})))
}
/// GET /api/tokens — list the current user's tokens (no hashes).
pub async fn tokens_list_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let tokens = store
.list_api_tokens(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let tokens_json: Vec<serde_json::Value> = tokens
.into_iter()
.map(|t| {
serde_json::json!({
"id": t.id.to_string(),
"name": t.name,
"token_prefix": t.token_prefix,
"expires_at": t.expires_at.map(|dt| dt.to_rfc3339()),
"last_used_at": t.last_used_at.map(|dt| dt.to_rfc3339()),
"created_at": t.created_at.to_rfc3339(),
"revoked_at": t.revoked_at.map(|dt| dt.to_rfc3339()),
})
})
.collect();
Ok(Json(serde_json::json!({ "tokens": tokens_json })))
}
/// DELETE /api/tokens/{id} — revoke a token.
pub async fn tokens_revoke_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let token_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid token ID".to_string()))?;
let revoked = store
.revoke_api_token(token_id, &user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !revoked {
return Err((StatusCode::NOT_FOUND, "Token not found".to_string()));
}
// Evict cached auth so revocation takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&user.user_id).await;
}
Ok(Json(serde_json::json!({
"status": "revoked",
"id": token_id.to_string(),
})))
}
-534
View File
@@ -1,534 +0,0 @@
//! User management API handlers (admin).
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use rand::RngCore;
use rand::rngs::OsRng;
use uuid::Uuid;
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
use crate::channels::web::server::GatewayState;
use crate::db::{Database, UserRecord};
/// Check whether `user_id` is the sole active admin. Returns true if demoting,
/// suspending, or deleting this user would leave zero admins.
async fn is_last_admin(store: &dyn Database, user_id: &str) -> Result<bool, String> {
let users = store
.list_users(Some("active"))
.await
.map_err(|e| e.to_string())?;
let active_admins: Vec<_> = users.iter().filter(|u| u.role == "admin").collect();
Ok(active_admins.len() == 1 && active_admins[0].id == user_id)
}
/// POST /api/admin/users — create a new user.
pub async fn users_create_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(user): AdminUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing or empty 'display_name'".to_string(),
))?
.to_string();
let email = body
.get("email")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from);
let role = body
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("member")
.to_string();
if role != "admin" && role != "member" {
return Err((
StatusCode::BAD_REQUEST,
"role must be 'admin' or 'member'".to_string(),
));
}
let user_id = Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let user_record = UserRecord {
id: user_id.clone(),
email,
display_name: display_name.clone(),
status: "active".to_string(),
role,
created_at: now,
updated_at: now,
last_login_at: None,
created_by: Some(user.user_id.clone()),
metadata: serde_json::json!({}),
};
// Generate a first API token so the new user can authenticate immediately.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let token_hash = crate::channels::web::auth::hash_token(&plaintext_token);
let token_prefix = &plaintext_token[..8];
// Create user and initial token atomically — if either fails, both roll back.
let _token_record = store
.create_user_with_token(&user_record, "initial", &token_hash, token_prefix, None)
.await
.map_err(|e| {
let msg = e.to_string();
let lower = msg.to_ascii_lowercase();
if lower.contains("unique")
|| lower.contains("duplicate")
|| lower.contains("already exists")
{
(StatusCode::CONFLICT, msg)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, msg)
}
})?;
Ok(Json(serde_json::json!({
"id": user_record.id,
"email": user_record.email,
"display_name": user_record.display_name,
"status": user_record.status,
"role": user_record.role,
"token": plaintext_token,
"created_at": user_record.created_at.to_rfc3339(),
"created_by": user_record.created_by,
})))
}
/// GET /api/admin/users — list all users with inline usage stats.
pub async fn users_list_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let users = store
.list_users(None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Fetch per-user summary stats from DB (agent_jobs + llm_calls).
let summary_stats = store
.user_summary_stats(None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let stats_map: std::collections::HashMap<String, _> = summary_stats
.into_iter()
.map(|s| (s.user_id.clone(), s))
.collect();
let mut users_json: Vec<serde_json::Value> = Vec::with_capacity(users.len());
for u in users {
let db_stats = stats_map.get(&u.id);
let total_cost = db_stats.map_or(rust_decimal::Decimal::ZERO, |s| s.total_cost);
// Last active: prefer DB timestamp, fall back to last_login_at.
let last_active = db_stats.and_then(|s| s.last_active_at).or(u.last_login_at);
users_json.push(serde_json::json!({
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"status": u.status,
"role": u.role,
"created_at": u.created_at.to_rfc3339(),
"updated_at": u.updated_at.to_rfc3339(),
"last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()),
"created_by": u.created_by,
"job_count": db_stats.map_or(0, |s| s.job_count),
"total_cost": total_cost.to_string(),
"last_active_at": last_active.map(|dt| dt.to_rfc3339()),
}));
}
Ok(Json(serde_json::json!({ "users": users_json })))
}
/// GET /api/admin/users/{id} — get a single user.
pub async fn users_detail_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let user_record = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": user_record.id,
"email": user_record.email,
"display_name": user_record.display_name,
"status": user_record.status,
"role": user_record.role,
"created_at": user_record.created_at.to_rfc3339(),
"updated_at": user_record.updated_at.to_rfc3339(),
"last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()),
"created_by": user_record.created_by,
"metadata": user_record.metadata,
})))
}
/// PATCH /api/admin/users/{id} — update a user's profile.
pub async fn users_update_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
let existing = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or(&existing.display_name);
let metadata = if let Some(m) = body.get("metadata") {
if !m.is_object() {
return Err((
StatusCode::BAD_REQUEST,
"metadata must be a JSON object".to_string(),
));
}
m
} else {
&existing.metadata
};
// Update role if provided and valid.
if let Some(role) = body.get("role").and_then(|v| v.as_str()) {
if role != "admin" && role != "member" {
return Err((
StatusCode::BAD_REQUEST,
"role must be 'admin' or 'member'".to_string(),
));
}
if role != existing.role {
// Prevent demoting the last admin.
if existing.role == "admin"
&& role == "member"
&& is_last_admin(store.as_ref(), &id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
{
return Err((
StatusCode::CONFLICT,
"Cannot demote the last admin".to_string(),
));
}
store
.update_user_role(&id, role)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Evict cached auth so role change takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&id).await;
}
}
}
store
.update_user_profile(&id, display_name, metadata)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Re-fetch the updated record to return consistent data.
let updated = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": updated.id,
"email": updated.email,
"display_name": updated.display_name,
"status": updated.status,
"role": updated.role,
"created_at": updated.created_at.to_rfc3339(),
"updated_at": updated.updated_at.to_rfc3339(),
"metadata": updated.metadata,
})))
}
/// POST /api/admin/users/{id}/suspend — suspend a user.
pub async fn users_suspend_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
// Prevent suspending the last admin.
if is_last_admin(store.as_ref(), &id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
{
return Err((
StatusCode::CONFLICT,
"Cannot suspend the last admin".to_string(),
));
}
store
.update_user_status(&id, "suspended")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Evict cached auth so suspension takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&id).await;
}
Ok(Json(serde_json::json!({
"id": id,
"status": "suspended",
})))
}
/// POST /api/admin/users/{id}/activate — activate a user.
pub async fn users_activate_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
store
.update_user_status(&id, "active")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Evict cached auth so reactivation takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&id).await;
}
Ok(Json(serde_json::json!({
"id": id,
"status": "active",
})))
}
/// DELETE /api/admin/users/{id} — delete a user and all their data.
pub async fn users_delete_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Prevent deleting the last admin.
if is_last_admin(store.as_ref(), &id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
{
return Err((
StatusCode::CONFLICT,
"Cannot delete the last admin".to_string(),
));
}
let deleted = store
.delete_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !deleted {
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
}
Ok(Json(serde_json::json!({
"id": id,
"deleted": true,
})))
}
/// GET /api/profile — get the authenticated user's own profile.
pub async fn profile_get_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let record = store
.get_user(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": record.id,
"email": record.email,
"display_name": record.display_name,
"status": record.status,
"role": record.role,
"created_at": record.created_at.to_rfc3339(),
"last_login_at": record.last_login_at.map(|dt| dt.to_rfc3339()),
})))
}
/// PATCH /api/profile — update the authenticated user's own profile.
pub async fn profile_update_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let current = store
.get_user(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or(&current.display_name);
let metadata = if let Some(m) = body.get("metadata") {
if !m.is_object() {
return Err((
StatusCode::BAD_REQUEST,
"metadata must be a JSON object".to_string(),
));
}
m
} else {
&current.metadata
};
store
.update_user_profile(&user.user_id, display_name, metadata)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": user.user_id,
"display_name": display_name,
"updated": true,
})))
}
/// GET /api/admin/usage — per-user LLM usage stats.
pub async fn usage_stats_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let user_id = params.get("user_id").map(|s| s.as_str());
let period = params.get("period").map(|s| s.as_str()).unwrap_or("day");
let since = match period {
"week" => chrono::Utc::now() - chrono::Duration::days(7),
"month" => chrono::Utc::now() - chrono::Duration::days(30),
_ => chrono::Utc::now() - chrono::Duration::days(1),
};
let stats = store
.user_usage_stats(user_id, since)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let entries: Vec<serde_json::Value> = stats
.iter()
.map(|s| {
serde_json::json!({
"user_id": s.user_id,
"model": s.model,
"call_count": s.call_count,
"input_tokens": s.input_tokens,
"output_tokens": s.output_tokens,
"total_cost": s.total_cost.to_string(),
})
})
.collect();
Ok(Json(serde_json::json!({
"period": period,
"since": since.to_rfc3339(),
"usage": entries,
})))
}
+7 -23
View File
@@ -15,6 +15,7 @@ use subtle::ConstantTimeEq;
use crate::agent::routine::Trigger;
use crate::channels::web::server::GatewayState;
use crate::channels::web::util::{sanitized_db_error, sanitized_routine_error};
/// Validate the webhook secret for a routine.
///
@@ -56,24 +57,13 @@ fn validate_webhook_secret(
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
///
/// **Single-user/backward-compatible**: looks up routines by path across all
/// users. Disabled in multi-tenant mode — use the user-scoped endpoint at
/// users. For multi-tenant isolation, use the user-scoped endpoint at
/// `/api/webhooks/u/{user_id}/{path}` instead.
pub async fn webhook_trigger_handler(
State(state): State<Arc<GatewayState>>,
Path(path): Path<String>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// In multi-tenant mode, reject unscoped webhooks to prevent cross-user
// routine triggering. The per-routine secret provides some protection,
// but tenant isolation requires scoping by user_id.
// Use workspace_pool as the multi-tenant indicator — it's only set when
// has_any_users() was true at startup (not just when a DB exists).
if state.workspace_pool.is_some() {
return Err((
StatusCode::GONE,
"Unscoped webhooks disabled in multi-tenant mode. Use /api/webhooks/u/{user_id}/{path} instead.".to_string(),
));
}
fire_webhook_inner(state, &path, None, &headers).await
}
@@ -114,7 +104,7 @@ async fn fire_webhook_inner(
let routine = store
.get_webhook_routine_by_path(path, user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| sanitized_db_error(e, "get webhook routine by path"))?
.ok_or((
StatusCode::NOT_FOUND,
"No routine matches this webhook path".to_string(),
@@ -137,16 +127,10 @@ async fn fire_webhook_inner(
))?
};
let run_id = engine.fire_webhook(routine.id, path).await.map_err(|e| {
let status = match &e {
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
crate::error::RoutineError::Disabled { .. }
| crate::error::RoutineError::Cooldown { .. }
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, e.to_string())
})?;
let run_id = engine
.fire_webhook(routine.id, path)
.await
.map_err(|e| sanitized_routine_error(e, "trigger routine from webhook"))?;
Ok(Json(serde_json::json!({
"status": "triggered",
+65 -34
View File
@@ -56,7 +56,7 @@ use crate::workspace::Workspace;
use self::log_layer::{LogBroadcaster, LogLevelHandle};
use self::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState};
use self::auth::MultiAuthState;
use self::server::GatewayState;
use self::sse::SseManager;
use self::types::AppEvent;
@@ -65,8 +65,8 @@ use self::types::AppEvent;
pub struct GatewayChannel {
config: GatewayConfig,
state: Arc<GatewayState>,
/// Combined auth state: env-var tokens + optional DB-backed tokens.
auth: CombinedAuthState,
/// Multi-user auth state (replaces bare auth_token).
auth: MultiAuthState,
}
impl GatewayChannel {
@@ -74,7 +74,7 @@ impl GatewayChannel {
///
/// If no auth token is configured, generates a random one and prints it.
/// Builds a single-user `MultiAuthState` from the config.
pub fn new(config: GatewayConfig, owner_id: String) -> Self {
pub fn new(config: GatewayConfig) -> Self {
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
use rand::RngCore;
use rand::rngs::OsRng;
@@ -83,10 +83,7 @@ impl GatewayChannel {
bytes.iter().map(|b| format!("{b:02x}")).collect()
});
let auth = CombinedAuthState {
env_auth: MultiAuthState::single(auth_token, owner_id.clone()),
db_auth: None,
};
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
@@ -102,7 +99,8 @@ impl GatewayChannel {
job_manager: None,
prompt_queue: None,
scheduler: None,
owner_id,
owner_id: config.user_id.clone(),
default_sender_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
@@ -116,8 +114,62 @@ impl GatewayChannel {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
Self {
config,
state,
auth,
}
}
/// Rebind the single-user auth identity to the durable owner scope while
/// preserving the configured gateway sender/routing identity.
pub fn with_owner_scope(mut self, owner_id: impl Into<String>) -> Self {
let owner_id = owner_id.into();
let single_user_token = if self.config.user_tokens.is_none() {
self.auth.first_token().map(ToOwned::to_owned)
} else {
None
};
if let Some(token) = single_user_token {
self.auth = MultiAuthState::single(token, owner_id.clone());
}
self.rebuild_state(|s| s.owner_id = owner_id);
self
}
/// Create a gateway channel with a pre-built multi-user auth state.
pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
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,
scheduler: None,
owner_id: config.user_id.clone(),
default_sender_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: server::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(),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
active_config: server::ActiveConfigSnapshot::default(),
});
Self {
@@ -145,6 +197,7 @@ impl GatewayChannel {
prompt_queue: self.state.prompt_queue.clone(),
scheduler: self.state.scheduler.clone(),
owner_id: self.state.owner_id.clone(),
default_sender_id: self.state.default_sender_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(),
llm_provider: self.state.llm_provider.clone(),
@@ -158,8 +211,6 @@ impl GatewayChannel {
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
secrets_store: self.state.secrets_store.clone(),
db_auth: self.state.db_auth.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -207,17 +258,6 @@ impl GatewayChannel {
self
}
/// Enable DB-backed token authentication alongside env-var tokens.
pub fn with_db_auth(mut self, store: Arc<dyn Database>) -> Self {
let authenticator = DbAuthenticator::new(store);
// Share the same DbAuthenticator (and its cache) between the auth
// middleware and GatewayState so handlers can invalidate the cache
// on security-critical actions (suspend, role change, token revoke).
self.rebuild_state(|s| s.db_auth = Some(Arc::new(authenticator.clone())));
self.auth.db_auth = Some(authenticator);
self
}
/// Inject the container job manager for sandbox operations.
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
self.rebuild_state(|s| s.job_manager = Some(jm));
@@ -288,15 +328,6 @@ impl GatewayChannel {
self
}
/// Inject the secrets store for admin secret provisioning.
pub fn with_secrets_store(
mut self,
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Self {
self.rebuild_state(|s| s.secrets_store = Some(store));
self
}
/// Inject the per-user workspace pool for multi-user mode.
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
self.rebuild_state(|s| s.workspace_pool = Some(pool));
@@ -305,7 +336,7 @@ impl GatewayChannel {
/// Get the first auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
self.auth.env_auth.first_token().unwrap_or("")
self.auth.first_token().unwrap_or("")
}
/// Get a reference to the shared gateway state (for the agent to push SSE events).
+140 -125
View File
@@ -16,7 +16,7 @@ use axum::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post, put},
routing::{get, post},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
@@ -31,7 +31,7 @@ use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
use crate::channels::web::auth::{
AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware,
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
};
use crate::channels::web::handlers::jobs::{
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
@@ -290,22 +290,7 @@ impl WorkspacePool {
}
let ws = Arc::new(ws);
cache.insert(identity.user_id.clone(), Arc::clone(&ws));
// Seed identity files after inserting into cache (so the lock can be
// dropped) but before returning, so callers see a seeded workspace.
// Drop the write lock explicitly before the async seed to avoid
// blocking other workspace lookups.
drop(cache);
if let Err(e) = ws.seed_if_empty().await {
tracing::warn!(
user_id = identity.user_id,
"Failed to seed workspace: {}",
e
);
}
ws
}
}
@@ -362,6 +347,8 @@ pub struct GatewayState {
pub prompt_queue: Option<PromptQueue>,
/// Durable owner scope for persistence and unauthenticated callback flows.
pub owner_id: String,
/// Default sender/routing identity for gateway-originated messages.
pub default_sender_id: String,
/// Shutdown signal sender.
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
/// WebSocket connection tracker.
@@ -391,10 +378,6 @@ pub struct GatewayState {
pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot,
/// Secrets store for admin secret provisioning.
pub secrets_store: Option<Arc<dyn crate::secrets::SecretsStore + Send + Sync>>,
/// DB auth cache for invalidation on security-critical actions.
pub db_auth: Option<Arc<crate::channels::web::auth::DbAuthenticator>>,
}
/// Start the gateway HTTP server.
@@ -403,7 +386,7 @@ pub struct GatewayState {
pub async fn start_server(
addr: SocketAddr,
state: Arc<GatewayState>,
auth: CombinedAuthState,
auth: MultiAuthState,
) -> Result<SocketAddr, crate::error::ChannelError> {
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
crate::error::ChannelError::StartupFailed {
@@ -529,57 +512,6 @@ pub async fn start_server(
"/api/settings/{key}",
axum::routing::delete(settings_delete_handler),
)
// User management (admin)
.route(
"/api/admin/users",
get(super::handlers::users::users_list_handler)
.post(super::handlers::users::users_create_handler),
)
.route(
"/api/admin/users/{id}",
get(super::handlers::users::users_detail_handler)
.patch(super::handlers::users::users_update_handler)
.delete(super::handlers::users::users_delete_handler),
)
.route(
"/api/admin/users/{id}/suspend",
post(super::handlers::users::users_suspend_handler),
)
.route(
"/api/admin/users/{id}/activate",
post(super::handlers::users::users_activate_handler),
)
// Admin secrets provisioning (per-user)
.route(
"/api/admin/users/{user_id}/secrets",
get(super::handlers::secrets::secrets_list_handler),
)
.route(
"/api/admin/users/{user_id}/secrets/{name}",
put(super::handlers::secrets::secrets_put_handler)
.delete(super::handlers::secrets::secrets_delete_handler),
)
// Usage reporting (admin)
.route(
"/api/admin/usage",
get(super::handlers::users::usage_stats_handler),
)
// User self-service profile
.route(
"/api/profile",
get(super::handlers::users::profile_get_handler)
.patch(super::handlers::users::profile_update_handler),
)
// Token management
.route(
"/api/tokens",
get(super::handlers::tokens::tokens_list_handler)
.post(super::handlers::tokens::tokens_create_handler),
)
.route(
"/api/tokens/{id}",
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
)
// Gateway control plane
.route("/api/gateway/status", get(gateway_status_handler))
// OpenAI-compatible API
@@ -639,7 +571,6 @@ pub async fn start_server(
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::PATCH,
axum::http::Method::DELETE,
])
.allow_headers(AllowHeaders::list([
@@ -654,33 +585,6 @@ pub async fn start_server(
.merge(projects)
.merge(protected)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads)
.layer(tower_http::catch_panic::CatchPanicLayer::custom(
|panic_info: Box<dyn std::any::Any + Send + 'static>| {
let detail = if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic_info.downcast_ref::<&str>() {
(*s).to_string()
} else {
"unknown panic".to_string()
};
// Truncate panic payload to avoid leaking sensitive data into logs.
// Use floor_char_boundary to avoid panicking on multi-byte UTF-8.
let safe_detail = if detail.len() > 200 {
let end = detail.floor_char_boundary(200);
format!("{}", &detail[..end])
} else {
detail
};
tracing::error!("Handler panicked: {}", safe_detail);
axum::http::Response::builder()
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "text/plain")
.body(axum::body::Body::from("Internal Server Error"))
.unwrap_or_else(|_| {
axum::http::Response::new(axum::body::Body::from("Internal Server Error"))
})
},
))
.layer(cors)
.layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS,
@@ -1428,6 +1332,9 @@ async fn chat_send_handler(
}
let mut msg = IncomingMessage::new("gateway", &user.user_id, &req.content);
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
msg = msg.with_sender_id(&state.default_sender_id);
}
// Prefer timezone from JSON body, fall back to X-Timezone header
let tz = req
.timezone
@@ -1529,6 +1436,9 @@ async fn chat_approval_handler(
})?;
let mut msg = IncomingMessage::new("gateway", &user.user_id, content);
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
msg = msg.with_sender_id(&state.default_sender_id);
}
if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id);
@@ -1807,7 +1717,13 @@ async fn chat_history_handler(
let (messages, has_more) = store
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
tracing::error!(error = %e, "DB error listing paginated messages");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
let turns = build_turns_from_db_messages(&messages);
@@ -1880,7 +1796,13 @@ async fn chat_history_handler(
let (messages, has_more) = store
.list_conversation_messages_paginated(thread_id, None, limit as i64)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
tracing::error!(error = %e, "DB error listing messages");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
if !messages.is_empty() {
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
@@ -1923,7 +1845,13 @@ async fn chat_threads_handler(
let assistant_id = store
.get_or_create_assistant_conversation(&user.user_id, "gateway")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
tracing::error!(error = %e, "DB error getting assistant conversation");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
match store
.list_conversations_all_channels(&user.user_id, 50)
@@ -2145,7 +2073,13 @@ async fn extensions_list_handler(
let installed = ext_mgr
.list(None, false, &user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
tracing::error!(error = %e, "Error listing extensions");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal error".to_string(),
)
})?;
let pairing_store = crate::pairing::PairingStore::new();
let mut owner_bound_channels = std::collections::HashSet::new();
@@ -2575,7 +2509,13 @@ async fn extensions_setup_handler(
let setup = ext_mgr
.get_setup_schema(&name, &user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
tracing::error!(error = %e, "Error getting extension setup schema");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal error".to_string(),
)
})?;
let kind = ext_mgr
.list(None, false, &user.user_id)
@@ -2649,9 +2589,13 @@ async fn pairing_list_handler(
Path(channel): Path<String>,
) -> Result<Json<PairingListResponse>, (StatusCode, String)> {
let store = crate::pairing::PairingStore::new();
let requests = store
.list_pending(&channel)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let requests = store.list_pending(&channel).map_err(|e| {
tracing::error!(error = %e, "Error listing pairing requests");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal error".to_string(),
)
})?;
let infos = requests
.into_iter()
@@ -2707,17 +2651,26 @@ async fn routines_runs_handler(
let routine = store
.get_routine(routine_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(|e| {
tracing::error!(error = %e, "DB error getting routine");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
if routine.user_id != user.user_id {
return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
}
let runs = store
.list_routine_runs(routine_id, 50)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let runs = store.list_routine_runs(routine_id, 50).await.map_err(|e| {
tracing::error!(error = %e, "DB error listing routine runs");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
})?;
let run_infos: Vec<RoutineRunInfo> = runs
.iter()
@@ -3115,8 +3068,10 @@ mod tests {
// --- OAuth callback handler tests ---
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
fn test_gateway_state(ext_mgr: Option<Arc<ExtensionManager>>) -> Arc<GatewayState> {
fn test_gateway_state_inner(
ext_mgr: Option<Arc<ExtensionManager>>,
store: Option<Arc<dyn crate::db::Database>>,
) -> Arc<GatewayState> {
Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: Arc::new(SseManager::new()),
@@ -3127,10 +3082,11 @@ mod tests {
log_level_handle: None,
extension_manager: ext_mgr,
tool_registry: None,
store: None,
store,
job_manager: None,
prompt_queue: None,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
@@ -3145,11 +3101,34 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
})
}
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
fn test_gateway_state(ext_mgr: Option<Arc<ExtensionManager>>) -> Arc<GatewayState> {
test_gateway_state_inner(ext_mgr, None)
}
fn test_gateway_state_with_store(
store: Arc<dyn crate::db::Database>,
ext_mgr: Option<Arc<ExtensionManager>>,
) -> Arc<GatewayState> {
test_gateway_state_inner(ext_mgr, Some(store))
}
#[cfg(feature = "libsql")]
async fn create_unmigrated_test_db() -> (Arc<dyn crate::db::Database>, tempfile::TempDir) {
use crate::db::libsql::LibSqlBackend;
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
let db: Arc<dyn crate::db::Database> = Arc::new(backend);
(db, temp_dir)
}
/// Build a test router with just the OAuth callback route.
fn test_oauth_router(state: Arc<GatewayState>) -> Router {
Router::new()
@@ -3367,7 +3346,6 @@ mod tests {
// without needing the full auth middleware layer.
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
});
@@ -3392,6 +3370,47 @@ mod tests {
);
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_routines_list_sanitizes_database_errors() {
use axum::body::Body;
use tower::ServiceExt;
let (db, _tmp) = create_unmigrated_test_db().await;
let state = test_gateway_state_with_store(db, None);
let app = Router::new()
.route(
"/api/routines",
get(crate::channels::web::handlers::routines::routines_list_handler),
)
.with_state(state);
let mut req = axum::http::Request::builder()
.method("GET")
.uri("/api/routines")
.body(Body::empty())
.expect("request");
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
});
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8 body");
assert_eq!(text, "Database error");
assert!(
!text.contains("no such table"),
"client response should not leak backend error details"
);
}
#[tokio::test]
async fn test_extensions_setup_submit_telegram_verification_does_not_broadcast_auth_required() {
use axum::body::Body;
@@ -3452,7 +3471,6 @@ mod tests {
// without needing the full auth middleware layer.
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
});
@@ -3502,10 +3520,7 @@ mod tests {
let state = test_gateway_state(None);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let auth = CombinedAuthState::from(crate::channels::web::auth::MultiAuthState::single(
"test-token".to_string(),
"test".to_string(),
));
let auth = MultiAuthState::single("test-token".to_string(), "test".to_string());
let bound = start_server(addr, state.clone(), auth)
.await
.expect("server should start");
-167
View File
@@ -186,13 +186,6 @@ function authenticate() {
connectSSE();
connectLogSSE();
startGatewayStatusPolling();
// Hide the Users settings tab for non-admin users.
apiFetch('/api/profile').then(function(profile) {
if (profile && profile.role !== 'admin') {
var usersTab = document.querySelector('[data-settings-subtab="users"]');
if (usersTab) usersTab.style.display = 'none';
}
}).catch(function() {});
checkTeeStatus();
loadThreads();
loadMemoryTree();
@@ -4346,165 +4339,6 @@ function formatRelativeTime(isoString) {
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
}
// --- Users (admin) ---
function loadUsers() {
apiFetch('/api/admin/users').then(function(data) {
renderUsersList(data.users || []);
}).catch(function(err) {
var tbody = document.getElementById('users-tbody');
var empty = document.getElementById('users-empty');
if (tbody) tbody.innerHTML = '';
if (empty) {
empty.style.display = 'block';
if (err.status === 403 || err.status === 401) {
empty.textContent = I18n.t('users.adminRequired');
} else {
empty.textContent = I18n.t('users.failedToLoad') + ': ' + err.message;
}
}
});
}
function renderUsersList(users) {
var tbody = document.getElementById('users-tbody');
var empty = document.getElementById('users-empty');
if (!users || users.length === 0) {
tbody.innerHTML = '';
empty.style.display = 'block';
empty.textContent = I18n.t('users.emptyState');
return;
}
empty.style.display = 'none';
tbody.innerHTML = users.map(function(u) {
var statusClass = u.status === 'active' ? 'active' : 'failed';
var roleLabel = u.role === 'admin' ? '<span class="badge badge-admin">' + I18n.t('users.roleAdmin') + '</span>' : '<span class="badge">' + I18n.t('users.roleMember') + '</span>';
var actions = '';
if (u.status === 'active') {
actions += '<button class="btn-small btn-danger" data-action="suspend-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.suspend') + '</button> ';
} else {
actions += '<button class="btn-small btn-primary" data-action="activate-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.activate') + '</button> ';
}
if (u.role === 'member') {
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="admin">' + I18n.t('users.makeAdmin') + '</button> ';
} else {
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="member">' + I18n.t('users.makeMember') + '</button> ';
}
actions += '<button class="btn-small" data-action="create-token" data-user-id="' + escapeHtml(u.id) + '" data-user-name="' + escapeHtml(u.display_name) + '">' + I18n.t('users.addToken') + '</button>';
return '<tr>'
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
+ '<td>' + escapeHtml(u.display_name) + '</td>'
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
+ '<td>' + roleLabel + '</td>'
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
+ '<td>' + (u.job_count || 0) + '</td>'
+ '<td>' + formatCost(u.total_cost) + '</td>'
+ '<td>' + (u.last_active_at ? formatRelativeTime(u.last_active_at) : '—') + '</td>'
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
+ '<td>' + actions + '</td>'
+ '</tr>';
}).join('');
}
function suspendUser(userId) {
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
.then(function() { loadUsers(); })
.catch(function(e) { alert(I18n.t('users.failedSuspend') + ': ' + e.message); });
}
function activateUser(userId) {
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
.then(function() { loadUsers(); })
.catch(function(e) { alert(I18n.t('users.failedActivate') + ': ' + e.message); });
}
function changeUserRole(userId, newRole) {
apiFetch('/api/admin/users/' + userId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole })
})
.then(function() { loadUsers(); })
.catch(function(e) { alert(I18n.t('users.failedRoleChange') + ': ' + e.message); });
}
function createTokenForUser(userId, displayName) {
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
if (!tokenName) return;
apiFetch('/api/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: tokenName, user_id: userId }),
}).then(function(data) {
showTokenBanner(data.token, I18n.t('users.tokenCreated'));
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
}
function showTokenBanner(tokenValue, title) {
var banner = document.getElementById('users-token-result');
if (!banner) return;
var heading = title || I18n.t('users.tokenCreated');
var loginUrl = window.location.origin + '/?token=' + encodeURIComponent(tokenValue);
banner.style.display = 'block';
banner.innerHTML = '<strong>' + escapeHtml(heading) + '</strong> ' + I18n.t('users.tokenShareMessage') + '<br>'
+ '<code class="token-display" id="token-copy-value">' + escapeHtml(loginUrl) + '</code>'
+ '<button class="btn-small" id="token-copy-link">Copy Link</button>'
+ '<br><span style="font-size:0.8em;color:var(--text-muted)">' + I18n.t('users.rawToken') + ' ' + escapeHtml(tokenValue) + '</span>';
document.getElementById('token-copy-link').addEventListener('click', function() {
navigator.clipboard.writeText(loginUrl);
this.textContent = I18n.t('users.copied');
});
}
// Delegated click handler for user action buttons (CSP-safe, no inline onclick)
document.getElementById('users-table')?.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
var userId = btn.getAttribute('data-user-id');
var userName = btn.getAttribute('data-user-name');
if (action === 'suspend-user') suspendUser(userId);
else if (action === 'activate-user') activateUser(userId);
else if (action === 'change-role') changeUserRole(userId, btn.getAttribute('data-role'));
else if (action === 'create-token') createTokenForUser(userId, userName || '');
});
// Wire up Users tab create form
document.getElementById('users-create-btn')?.addEventListener('click', function() {
document.getElementById('users-create-form').style.display = 'flex';
document.getElementById('users-token-result').style.display = 'none';
document.getElementById('user-display-name').focus();
});
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
document.getElementById('users-create-form').style.display = 'none';
});
document.getElementById('users-create-submit')?.addEventListener('click', function() {
var displayName = document.getElementById('user-display-name').value.trim();
var email = document.getElementById('user-email').value.trim();
var role = document.getElementById('user-role').value;
if (!displayName) { alert(I18n.t('users.displayNameRequired')); return; }
apiFetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
display_name: displayName,
email: email || undefined,
role: role,
}),
}).then(function(data) {
document.getElementById('users-create-form').style.display = 'none';
document.getElementById('user-display-name').value = '';
document.getElementById('user-email').value = '';
if (data.token) {
showTokenBanner(data.token, I18n.t('users.userCreated'));
}
loadUsers();
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
});
// --- Gateway status widget ---
let gatewayStatusInterval = null;
@@ -5194,7 +5028,6 @@ function loadSettingsSubtab(subtab) {
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
else if (subtab === 'mcp') loadMcpServers();
else if (subtab === 'skills') loadSkills();
else if (subtab === 'users') loadUsers();
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
}
+3 -40
View File
@@ -9,6 +9,8 @@ I18n.register('en', {
'auth.connect': 'Connect',
'auth.errorRequired': 'Token required',
'auth.errorInvalid': 'Invalid token',
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
// Chat
'chat.inputPlaceholder': 'Message or / for commands...',
@@ -42,46 +44,7 @@ I18n.register('en', {
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
'settings.users': 'Users',
// Users Tab
'users.heading': 'User Management',
'users.newUser': '+ New User',
'users.displayNamePlaceholder': 'Display name',
'users.emailPlaceholder': 'Email (optional)',
'users.roleMember': 'Member',
'users.roleAdmin': 'Admin',
'users.create': 'Create',
'users.cancel': 'Cancel',
'users.emptyState': 'No users found. Create the first user to get started.',
'users.adminRequired': 'Admin access required to manage users.',
'users.failedToLoad': 'Failed to load users',
'users.suspend': 'Suspend',
'users.activate': 'Activate',
'users.addToken': '+ Token',
'users.failedSuspend': 'Failed to suspend user',
'users.failedActivate': 'Failed to activate user',
'users.makeAdmin': 'Make Admin',
'users.makeMember': 'Make Member',
'users.failedRoleChange': 'Failed to change role',
'users.userCreated': 'User created!',
'users.tokenCreated': 'Token created!',
'users.tokenShareMessage': "Share this login link — it won't be shown again:",
'users.rawToken': 'Raw token:',
'users.copied': 'Copied!',
'users.displayNameRequired': 'Display name is required',
'users.failedCreate': 'Failed to create user',
'users.columns.id': 'ID',
'users.columns.displayName': 'Display Name',
'users.columns.email': 'Email',
'users.columns.role': 'Role',
'users.columns.status': 'Status',
'users.columns.jobs': 'Jobs',
'users.columns.cost': 'Cost',
'users.columns.lastActive': 'Last Active',
'users.columns.created': 'Created',
'users.columns.actions': 'Actions',
// Status
'status.connected': 'Connected',
'status.disconnected': 'Disconnected',
+3 -40
View File
@@ -9,6 +9,8 @@ I18n.register('zh-CN', {
'auth.connect': '连接',
'auth.errorRequired': '请输入令牌',
'auth.errorInvalid': '令牌无效',
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
// 聊天
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
@@ -42,46 +44,7 @@ I18n.register('zh-CN', {
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
'settings.users': '用户管理',
// 用户管理标签页
'users.heading': '用户管理',
'users.newUser': '+ 新用户',
'users.displayNamePlaceholder': '显示名称',
'users.emailPlaceholder': '邮箱(可选)',
'users.roleMember': '成员',
'users.roleAdmin': '管理员',
'users.create': '创建',
'users.cancel': '取消',
'users.emptyState': '暂无用户。创建第一个用户以开始使用。',
'users.adminRequired': '需要管理员权限来管理用户。',
'users.failedToLoad': '加载用户列表失败',
'users.suspend': '停用',
'users.activate': '启用',
'users.addToken': '+ 令牌',
'users.failedSuspend': '停用用户失败',
'users.failedActivate': '启用用户失败',
'users.makeAdmin': '设为管理员',
'users.makeMember': '设为成员',
'users.failedRoleChange': '更改角色失败',
'users.userCreated': '用户已创建!',
'users.tokenCreated': '令牌已创建!',
'users.tokenShareMessage': '分享此登录链接——此链接只会显示一次:',
'users.rawToken': '原始令牌:',
'users.copied': '已复制!',
'users.displayNameRequired': '显示名称为必填项',
'users.failedCreate': '创建用户失败',
'users.columns.id': 'ID',
'users.columns.displayName': '显示名称',
'users.columns.email': '邮箱',
'users.columns.role': '角色',
'users.columns.status': '状态',
'users.columns.jobs': '任务',
'users.columns.cost': '费用',
'users.columns.lastActive': '最近活跃',
'users.columns.created': '创建时间',
'users.columns.actions': '操作',
// 状态
'status.connected': '已连接',
'status.disconnected': '已断开',
+1 -24
View File
@@ -41,6 +41,7 @@
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -292,7 +293,6 @@
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
<button class="settings-subtab" data-settings-subtab="users" data-i18n="settings.users">Users</button>
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
</div>
<div class="settings-content">
@@ -390,29 +390,6 @@
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-users">
<div class="users-container">
<div class="users-header">
<h3 data-i18n="users.heading">User Management</h3>
<button id="users-create-btn" class="btn-primary" data-i18n="users.newUser">+ New User</button>
</div>
<div id="users-create-form" style="display:none" class="users-form" autocomplete="off">
<input type="text" id="user-display-name" data-i18n-placeholder="users.displayNamePlaceholder" placeholder="Display name" autocomplete="off" />
<input type="text" id="user-email" data-i18n-placeholder="users.emailPlaceholder" placeholder="Email (optional)" autocomplete="off" />
<select id="user-role"><option value="member" data-i18n="users.roleMember">Member</option><option value="admin" data-i18n="users.roleAdmin">Admin</option></select>
<button id="users-create-submit" class="btn-primary" data-i18n="users.create">Create</button>
<button id="users-create-cancel" class="btn-secondary" data-i18n="users.cancel">Cancel</button>
</div>
<div id="users-token-result" style="display:none" class="users-token-banner"></div>
<table class="routines-table" id="users-table">
<thead><tr>
<th data-i18n="users.columns.id">ID</th><th data-i18n="users.columns.displayName">Display Name</th><th data-i18n="users.columns.email">Email</th><th data-i18n="users.columns.role">Role</th><th data-i18n="users.columns.status">Status</th><th data-i18n="users.columns.jobs">Jobs</th><th data-i18n="users.columns.cost">Cost</th><th data-i18n="users.columns.lastActive">Last Active</th><th data-i18n="users.columns.created">Created</th><th data-i18n="users.columns.actions">Actions</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div id="users-empty" class="empty-state" style="display:none" data-i18n="users.emptyState">No users found. Create the first user to get started.</div>
</div>
</div>
</div>
</div>
</div>
-19
View File
@@ -5429,22 +5429,3 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
--text-muted: #a1a1aa;
}
}
/* --- Users Tab --- */
.users-container { padding: 1rem; }
.users-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.users-header h3 { margin: 0; font-size: 1.1rem; }
.users-form { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.users-form input, .users-form select { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem; }
.users-token-banner { background: var(--bg-tertiary); border: 1px solid var(--accent); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
.token-display { display: inline-block; padding: 0.3rem 0.6rem; background: var(--bg-primary); border-radius: 4px; font-family: var(--font-mono); word-break: break-all; margin: 0.4rem 0; user-select: all; }
.user-id { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; background: var(--bg-tertiary); color: var(--text-secondary); }
.badge-admin { background: var(--accent); color: #fff; }
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
.btn-small:hover { background: var(--bg-tertiary); }
.btn-danger { border-color: #ef4444; color: #ef4444; }
.btn-danger:hover { background: #ef4444; color: #fff; }
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
+3 -4
View File
@@ -77,6 +77,7 @@ impl TestGatewayBuilder {
job_manager: None,
prompt_queue: None,
owner_id: self.user_id.clone(),
default_sender_id: self.user_id,
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: self.llm_provider,
@@ -91,8 +92,6 @@ impl TestGatewayBuilder {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
})
}
@@ -107,7 +106,7 @@ impl TestGatewayBuilder {
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.into()).await?;
let bound = start_server(addr, state.clone(), auth).await?;
Ok((bound, state))
}
@@ -121,7 +120,7 @@ impl TestGatewayBuilder {
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.into()).await?;
let bound = start_server(addr, state.clone(), auth).await?;
Ok((bound, state))
}
}
+40 -159
View File
@@ -16,6 +16,7 @@ use axum::routing::{delete, get, post};
use tower::ServiceExt;
use uuid::Uuid;
use crate::channels::web::GatewayChannel;
use crate::channels::web::auth::{
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
};
@@ -23,6 +24,7 @@ use crate::channels::web::server::{
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
};
use crate::channels::web::sse::SseManager;
use crate::config::GatewayConfig;
// ── Helpers ────────────────────────────────────────────────────────────
@@ -33,7 +35,6 @@ fn two_user_auth() -> MultiAuthState {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -41,7 +42,6 @@ fn two_user_auth() -> MultiAuthState {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
},
);
@@ -67,6 +67,7 @@ fn build_state(
job_manager: None,
prompt_queue,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
@@ -81,11 +82,43 @@ fn build_state(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
})
}
fn gateway_config() -> GatewayConfig {
GatewayConfig {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("gateway-auth".to_string()),
user_id: "gateway-sender".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
}
}
#[test]
fn with_owner_scope_updates_gateway_owner_scope_in_multi_user_mode() {
let mut gateway = GatewayChannel::new(gateway_config());
gateway.auth = two_user_auth();
gateway.config.user_tokens = Some(HashMap::new());
let gateway = gateway.with_owner_scope("owner-scope");
assert_eq!(gateway.state.owner_id, "owner-scope");
assert_eq!(gateway.state.default_sender_id, "gateway-sender");
let alice = gateway
.auth
.authenticate("tok-alice")
.expect("alice token should remain valid");
let bob = gateway
.auth
.authenticate("tok-bob")
.expect("bob token should remain valid");
assert_eq!(alice.user_id, "alice");
assert_eq!(bob.user_id, "bob");
}
/// Create a libSQL-backed test database in a temporary directory.
///
/// Returns the database and a `TempDir` guard — the database file is
@@ -192,7 +225,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let ws = pool.get_or_create(&identity).await;
@@ -221,7 +253,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let ws = pool.get_or_create(&identity).await;
@@ -245,7 +276,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
};
let ws = pool.get_or_create(&identity).await;
@@ -272,12 +302,10 @@ mod workspace_pool {
);
let alice_id = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let bob_id = UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
@@ -309,7 +337,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["token-scope".to_string()],
};
let ws = pool.get_or_create(&identity).await;
@@ -350,10 +377,7 @@ mod jobs_isolation {
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.with_state(state)
}
@@ -559,10 +583,7 @@ mod routines_isolation {
.route("/api/routines/{id}", get(routines_detail_handler))
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
.route("/api/routines/{id}", delete(routines_delete_handler))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.with_state(state)
}
@@ -687,10 +708,7 @@ mod auth_enforcement {
.route("/api/logs/level", get(authed_handler).put(authed_handler))
// Gateway status
.route("/api/gateway/status", get(authed_handler))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.with_state(state)
}
@@ -813,140 +831,3 @@ mod auth_enforcement {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
// ═══════════════════════════════════════════════════════════════════════
// Admin Endpoint Role Enforcement Tests
// ═══════════════════════════════════════════════════════════════════════
mod admin_role_enforcement {
use super::*;
use crate::channels::web::handlers::users::{
users_activate_handler, users_detail_handler, users_list_handler, users_suspend_handler,
users_update_handler,
};
use axum::routing::patch;
/// Build a router with admin user endpoints behind multi-user auth.
/// Uses a member-role token and an admin-role token.
fn admin_router() -> Router {
let mut tokens = HashMap::new();
tokens.insert(
"tok-admin".to_string(),
UserIdentity {
user_id: "admin-user".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
},
);
tokens.insert(
"tok-member".to_string(),
UserIdentity {
user_id: "member-user".to_string(),
role: "member".to_string(),
workspace_read_scopes: vec![],
},
);
let auth = MultiAuthState::multi(tokens);
let state = build_state(None, None);
Router::new()
.route("/api/admin/users", get(users_list_handler))
.route("/api/admin/users/{id}", get(users_detail_handler))
.route("/api/admin/users/{id}", patch(users_update_handler))
.route("/api/admin/users/{id}/suspend", post(users_suspend_handler))
.route(
"/api/admin/users/{id}/activate",
post(users_activate_handler),
)
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state)
}
/// Assert a request returns FORBIDDEN for a member token.
async fn assert_forbidden_for_member(app: &Router, method: Method, uri: &str) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("Authorization", "Bearer tok-member")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"expected 403 for member on {}",
uri
);
}
#[tokio::test]
async fn test_admin_user_endpoints_reject_member_role() {
let app = admin_router();
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users").await;
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users/some-id").await;
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/suspend").await;
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/activate").await;
}
#[tokio::test]
async fn test_admin_user_endpoints_accept_admin_role() {
let app = admin_router();
// Admin token should pass auth (will get 503 since no DB, but not 403).
let req = Request::builder()
.uri("/api/admin/users")
.header("Authorization", "Bearer tok-admin")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"admin should not get 403"
);
}
}
// ═══════════════════════════════════════════════════════════════════════
// DbAuthenticator Cache Bounded Tests
// ═══════════════════════════════════════════════════════════════════════
mod db_auth_cache {
use super::*;
use std::time::Instant;
#[tokio::test]
async fn test_cache_bounded_by_max_entries() {
// Access the internal cache and verify LRU eviction.
// We can't easily test through `authenticate()` since it hits the DB,
// so we test the LRU cache directly.
let cap = std::num::NonZeroUsize::new(4).unwrap(); // safety: test-only, 4 is non-zero
let cache: lru::LruCache<[u8; 32], (UserIdentity, Instant)> = lru::LruCache::new(cap);
let cache = Arc::new(tokio::sync::RwLock::new(cache));
{
let mut c = cache.write().await;
for i in 0..10u8 {
let mut hash = [0u8; 32];
hash[0] = i;
c.put(
hash,
(
UserIdentity {
user_id: format!("user-{i}"),
role: "member".to_string(),
workspace_read_scopes: vec![],
},
Instant::now(),
),
);
}
// Cache must be bounded at capacity, not grown to 10.
assert_eq!(c.len(), 4, "cache should be bounded to capacity"); // safety: test assertion
}
}
}
+81
View File
@@ -1,5 +1,9 @@
//! Shared utility functions for the web gateway.
use std::fmt::Display;
use axum::http::StatusCode;
use crate::channels::web::types::{ToolCallInfo, TurnInfo};
pub use ironclaw_common::truncate_preview;
@@ -9,6 +13,59 @@ pub fn tool_error_for_display(error: &str) -> String {
ironclaw_safety::SafetyLayer::unwrap_tool_output(error).unwrap_or_else(|| error.to_string())
}
fn sanitized_internal_error<E: Display>(
error: E,
context: &str,
client_message: &str,
) -> (StatusCode, String) {
tracing::error!(error = %error, context, "Web gateway request failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
client_message.to_string(),
)
}
/// Log a detailed backend error while returning a generic DB message to the client.
pub fn sanitized_db_error<E: Display>(error: E, context: &str) -> (StatusCode, String) {
sanitized_internal_error(error, context, "Database error")
}
/// Log a detailed backend error while returning a generic internal message to the client.
pub fn sanitized_internal_error_response<E: Display>(
error: E,
context: &str,
) -> (StatusCode, String) {
sanitized_internal_error(error, context, "Internal error")
}
/// Return safe client responses for `RoutineError` while preserving user-actionable variants.
pub fn sanitized_routine_error(
error: crate::error::RoutineError,
context: &str,
) -> (StatusCode, String) {
use crate::error::RoutineError;
match error {
err @ RoutineError::NotFound { .. } => (StatusCode::NOT_FOUND, err.to_string()),
err @ RoutineError::NotAuthorized { .. } => (StatusCode::FORBIDDEN, err.to_string()),
err @ RoutineError::Disabled { .. }
| err @ RoutineError::Cooldown { .. }
| err @ RoutineError::MaxConcurrent { .. } => (StatusCode::CONFLICT, err.to_string()),
err @ RoutineError::Database { .. } => sanitized_db_error(err, context),
err @ RoutineError::LlmFailed { .. }
| err @ RoutineError::JobDispatchFailed { .. }
| err @ RoutineError::EmptyResponse
| err @ RoutineError::TruncatedResponse
| err @ RoutineError::UnknownTriggerType { .. }
| err @ RoutineError::UnknownActionType { .. }
| err @ RoutineError::MissingField { .. }
| err @ RoutineError::InvalidCron { .. }
| err @ RoutineError::UnknownRunStatus { .. } => {
sanitized_internal_error_response(err, context)
}
}
}
/// Parse tool call summary JSON objects into `ToolCallInfo` structs.
fn parse_tool_call_infos(calls: &[serde_json::Value]) -> Vec<ToolCallInfo> {
calls
@@ -128,6 +185,30 @@ mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_sanitized_db_error_hides_internal_details() {
let (_, body) = sanitized_db_error("sqlite: no such table: routines", "list routines");
assert_eq!(body, "Database error");
}
#[test]
fn test_sanitized_internal_error_hides_internal_details() {
let (_, body) =
sanitized_internal_error_response("container launch failed: timeout", "restart job");
assert_eq!(body, "Internal error");
}
#[test]
fn test_sanitized_routine_error_hides_database_details() {
let (_, body) = sanitized_routine_error(
crate::error::RoutineError::Database {
reason: "sqlite: no such table: routine_runs".to_string(),
},
"trigger routine",
);
assert_eq!(body, "Database error");
}
// ---- build_turns_from_db_messages tests ----
fn make_msg(role: &str, content: &str, offset_ms: i64) -> crate::history::ConversationMessage {
+1 -2
View File
@@ -521,6 +521,7 @@ mod tests {
prompt_queue: None,
scheduler: None,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -534,8 +535,6 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
}
}
}
+1 -1
View File
@@ -352,7 +352,7 @@ pub async fn run_routines_cli(
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
let user_id = std::env::var("IRONCLAW_OWNER_ID").unwrap_or_else(|_| "default".to_string());
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
run_routines_command(routines_cmd.clone(), db, &user_id).await
}
+4 -122
View File
@@ -569,42 +569,6 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
const HOSTED_STATE_PREFIX: &str = "ic2";
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
/// Maximum length for a legacy flow ID or instance name.
const LEGACY_STATE_MAX_LEN: usize = 128;
/// Minimum length for a legacy flow ID.
const LEGACY_STATE_MIN_LEN: usize = 8;
/// Validate that a legacy state component (flow_id or instance_name) contains
/// only safe characters: alphanumeric, dash, underscore.
fn is_valid_legacy_state_component(s: &str) -> bool {
!s.is_empty()
&& s.len() <= LEGACY_STATE_MAX_LEN
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn validate_legacy_flow_id(flow_id: &str) -> Result<(), String> {
if flow_id.len() < LEGACY_STATE_MIN_LEN {
return Err(format!(
"Legacy OAuth flow_id too short ({} chars, minimum {LEGACY_STATE_MIN_LEN})",
flow_id.len()
));
}
if flow_id.len() > LEGACY_STATE_MAX_LEN {
return Err(format!(
"Legacy OAuth flow_id too long ({} chars, maximum {LEGACY_STATE_MAX_LEN})",
flow_id.len()
));
}
if !flow_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err("Legacy OAuth flow_id contains invalid characters".to_string());
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedHostedOAuthState {
pub flow_id: String,
@@ -689,17 +653,6 @@ pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState,
if flow_id.is_empty() {
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
}
validate_legacy_flow_id(flow_id)?;
if !instance_name.is_empty() && !is_valid_legacy_state_component(instance_name) {
return Err(format!(
"Legacy OAuth instance name contains invalid characters or exceeds max length ({LEGACY_STATE_MAX_LEN})"
));
}
tracing::debug!(
flow_id,
instance_name,
"Decoded legacy prefixed OAuth state"
);
return Ok(DecodedHostedOAuthState {
flow_id: flow_id.to_string(),
instance_name: if instance_name.is_empty() {
@@ -715,9 +668,6 @@ pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState,
return Err("Hosted OAuth state is empty".to_string());
}
validate_legacy_flow_id(state)?;
tracing::debug!(flow_id = state, "Decoded legacy raw OAuth state");
Ok(DecodedHostedOAuthState {
flow_id: state.to_string(),
instance_name: None,
@@ -1784,13 +1734,13 @@ mod tests {
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded = decode_hosted_oauth_state("kind-deer:abc12345").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc12345");
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(decoded.is_legacy);
let decoded = decode_hosted_oauth_state("abc12345").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc12345");
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
@@ -1914,72 +1864,4 @@ mod tests {
assert_eq!(decoded_no_instance.instance_name, None);
assert!(!decoded_no_instance.is_legacy);
}
/// Legacy flow IDs that are too short must be rejected (#1443).
#[test]
fn test_legacy_state_rejects_short_flow_id() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let err = decode_hosted_oauth_state("abc").expect_err("short raw flow_id");
assert!(err.contains("too short"), "unexpected error: {err}");
let err = decode_hosted_oauth_state("inst:abc").expect_err("short prefixed flow_id");
assert!(err.contains("too short"), "unexpected error: {err}");
}
/// Legacy flow IDs with invalid characters must be rejected (#1443).
#[test]
fn test_legacy_state_rejects_invalid_characters() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let err = decode_hosted_oauth_state("flow id with spaces!").expect_err("spaces in flow_id");
assert!(
err.contains("invalid characters"),
"unexpected error: {err}"
);
let err = decode_hosted_oauth_state("inst:flow/id?bad=yes")
.expect_err("special chars in prefixed flow_id");
assert!(
err.contains("invalid characters"),
"unexpected error: {err}"
);
}
/// Legacy instance names with invalid characters must be rejected (#1444).
#[test]
fn test_legacy_state_rejects_invalid_instance_name() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let err = decode_hosted_oauth_state("bad instance!:valid-flow-id-12345")
.expect_err("invalid instance name");
assert!(err.contains("instance name"), "unexpected error: {err}");
}
/// Excessively long legacy flow IDs must be rejected (#1443).
#[test]
fn test_legacy_state_rejects_oversized_flow_id() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let long_id = "a".repeat(200);
let err = decode_hosted_oauth_state(&long_id).expect_err("oversized flow_id");
assert!(err.contains("too long"), "unexpected error: {err}");
}
/// Valid legacy flow IDs at boundary lengths are accepted.
#[test]
fn test_legacy_state_accepts_boundary_lengths() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
// Exactly 8 chars (minimum)
let decoded = decode_hosted_oauth_state("abcd1234").expect("8-char flow_id");
assert_eq!(decoded.flow_id, "abcd1234");
assert!(decoded.is_legacy);
// Exactly 128 chars (maximum)
let max_id = "a".repeat(128);
let decoded = decode_hosted_oauth_state(&max_id).expect("128-char flow_id");
assert_eq!(decoded.flow_id, max_id);
assert!(decoded.is_legacy);
}
}
+5 -7
View File
@@ -1,6 +1,6 @@
use std::time::Duration;
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -31,12 +31,10 @@ pub struct AgentConfig {
pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String,
/// Maximum concurrent jobs per user. None = use global max_parallel_jobs.
pub max_jobs_per_user: Option<usize>,
/// Maximum tokens per job (0 = unlimited).
pub max_tokens_per_job: u64,
/// Whether the deployment is multi-tenant (multiple users sharing one
/// instance). Defaults to false; can be set via AGENT_MULTI_TENANT env var.
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
pub multi_tenant: bool,
/// Maximum concurrent LLM calls per user. None = use default (4).
pub max_llm_concurrent_per_user: Option<usize>,
@@ -64,7 +62,6 @@ impl AgentConfig {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -125,12 +122,13 @@ impl AgentConfig {
}
tz
},
max_jobs_per_user: parse_option_env("MAX_JOBS_PER_USER")?,
max_tokens_per_job: parse_optional_env(
"AGENT_MAX_TOKENS_PER_JOB",
settings.agent.max_tokens_per_job,
)?,
multi_tenant: parse_bool_env("AGENT_MULTI_TENANT", false)?,
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
// knob — multi-tenant mode is always implied by configuring user tokens.
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
})
+65 -4
View File
@@ -1,11 +1,13 @@
use std::collections::HashMap;
use std::path::PathBuf;
use secrecy::SecretString;
use serde::Deserialize;
use crate::bootstrap::ironclaw_base_dir;
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
use secrecy::SecretString;
/// Channel configurations.
#[derive(Debug, Clone)]
@@ -43,15 +45,27 @@ pub struct GatewayConfig {
pub port: u16,
/// Bearer token for authentication. Random hex generated at startup if unset.
pub auth_token: Option<String>,
pub user_id: String,
/// Additional user scopes for workspace reads.
///
/// When set, the workspace will be able to read (search, read, list) from
/// these additional user scopes while writes remain isolated to the
/// authenticated user's own scope.
/// these additional user scopes while writes remain isolated to `user_id`.
/// Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
pub workspace_read_scopes: Vec<String>,
/// Memory layer definitions (JSON in env var, or from external config).
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
/// Multi-user token map. When set, each token maps to a user identity.
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
/// to single-user mode via `auth_token` + `user_id`.
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
}
/// Per-user token configuration for multi-user mode.
#[derive(Debug, Clone, Deserialize)]
pub struct UserTokenConfig {
pub user_id: String,
#[serde(default)]
pub workspace_read_scopes: Vec<String>,
}
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
@@ -118,6 +132,10 @@ impl ChannelsConfig {
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
let user_id = optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| owner_id.to_string());
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
@@ -126,7 +144,7 @@ impl ChannelsConfig {
message: format!("must be valid JSON array of layer objects: {e}"),
})?
}
None => crate::workspace::layer::MemoryLayer::default_for_user(owner_id),
None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id),
};
// Validate layer names and scopes
@@ -178,6 +196,41 @@ impl ChannelsConfig {
}
}
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
match optional_env("GATEWAY_USER_TOKENS")? {
Some(json_str) => {
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
&json_str,
)
.map_err(|e| ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"must be valid JSON object mapping tokens to user configs: {e}"
),
})?;
if tokens.is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message:
"token map is empty — remove the variable to use single-user mode"
.to_string(),
});
}
for (tok, cfg) in &tokens {
if cfg.user_id.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"token '{}...' has an empty user_id",
&tok[..tok.len().min(8)]
),
});
}
}
Some(tokens)
}
None => None,
};
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
.map(|s| {
s.split(',')
@@ -205,8 +258,10 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id,
workspace_read_scopes,
memory_layers,
user_tokens,
})
} else {
None
@@ -361,12 +416,15 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("tok-abc".to_string()),
user_id: "default".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 3000);
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
assert_eq!(cfg.user_id, "default");
}
#[test]
@@ -375,8 +433,10 @@ mod tests {
host: "0.0.0.0".to_string(),
port: 3001,
auth_token: None,
user_id: "anon".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert!(cfg.auth_token.is_none());
}
@@ -503,6 +563,7 @@ mod tests {
assert_eq!(gateway.host, "127.0.0.3");
assert_eq!(gateway.port, 9191);
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
assert_eq!(gateway.user_id, "owner-scope");
let signal = cfg.signal.expect("signal config");
assert_eq!(signal.account, "+15551234567");
+8 -3
View File
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
pub quiet_hours_end: Option<u32>,
/// Timezone for fire_at and quiet hours evaluation (IANA name).
pub timezone: Option<String>,
/// When true, cycle through all users with routines. Controlled via
/// HEARTBEAT_MULTI_TENANT env var; defaults to false.
/// When true, cycle through all users with routines. Auto-detected from
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
pub multi_tenant: bool,
}
@@ -105,7 +105,12 @@ impl HeartbeatConfig {
}
tz
},
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?,
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
multi_tenant: parse_bool_env(
"HEARTBEAT_MULTI_TENANT",
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
)?,
})
}
}
-14
View File
@@ -195,20 +195,6 @@ impl ContextManager {
.collect()
}
/// Count jobs consuming a parallel execution slot for a specific user.
///
/// Uses `is_parallel_blocking()` (Pending/InProgress/Stuck) rather than
/// `is_active()`, so Completed/Submitted jobs don't count against the
/// per-user concurrency limit.
pub async fn parallel_blocking_count_for(&self, user_id: &str) -> usize {
self.contexts
.read()
.await
.iter()
.filter(|(_, c)| c.user_id == user_id && c.state.is_parallel_blocking())
.count()
}
/// List all job IDs for a specific user.
pub async fn all_jobs_for(&self, user_id: &str) -> Vec<Uuid> {
self.contexts
+13 -18
View File
@@ -12,11 +12,11 @@ mod routines;
mod sandbox;
mod settings;
mod tool_failures;
mod users;
mod workspace;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, Utc};
@@ -33,6 +33,8 @@ use crate::workspace::MemoryDocument;
use crate::db::libsql_migrations;
static NAIVE_TIMESTAMP_LOGGED: AtomicBool = AtomicBool::new(false);
/// Explicit column list for routines table (matches positional access in `row_to_routine_libsql`).
pub(crate) const ROUTINE_COLUMNS: &str = "\
id, name, description, user_id, enabled, \
@@ -164,11 +166,13 @@ impl LibSqlBackend {
///
/// Returns an error if none of the formats match.
pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
let log_naive_timestamp = || {
tracing::warn!(
timestamp = %s,
"parsed naive timestamp, assuming UTC — consider migrating to RFC 3339"
);
let log_naive_timestamp_once = || {
if !NAIVE_TIMESTAMP_LOGGED.swap(true, Ordering::Relaxed) {
tracing::debug!(
timestamp = %s,
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
);
}
};
// RFC 3339 (our canonical write format)
@@ -177,12 +181,12 @@ pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
}
// Naive with fractional seconds (legacy or SQLite datetime() output)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
log_naive_timestamp();
log_naive_timestamp_once();
return Ok(ndt.and_utc());
}
// Naive without fractional seconds (legacy format)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
log_naive_timestamp();
log_naive_timestamp_once();
return Ok(ndt.and_utc());
}
Err(format!("unparseable timestamp: {:?}", s))
@@ -434,7 +438,7 @@ mod tests {
use chrono::{TimeZone, Utc};
use crate::db::Database;
use crate::db::libsql::{LibSqlBackend, fmt_ts, normalize_notify_user, parse_timestamp};
use crate::db::libsql::{LibSqlBackend, normalize_notify_user, parse_timestamp};
#[test]
fn test_normalize_notify_user_treats_legacy_default_as_missing() {
@@ -463,15 +467,6 @@ mod tests {
assert_eq!(naive_without_millis, expected);
}
#[test]
fn test_fmt_ts_roundtrips_through_parse_timestamp() {
let original = Utc.with_ymd_and_hms(2026, 6, 15, 8, 30, 45).unwrap()
+ chrono::Duration::milliseconds(123);
let formatted = fmt_ts(&original);
let parsed = parse_timestamp(&formatted).unwrap();
assert_eq!(parsed, original);
}
#[tokio::test]
async fn test_libsql_now_format_is_rfc3339_and_parseable() {
let backend = LibSqlBackend::new_memory().await.unwrap();
File diff suppressed because it is too large Load Diff
-62
View File
@@ -579,36 +579,6 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
-- ==================== User management (V14) ====================
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BLOB NOT NULL,
token_prefix TEXT NOT NULL,
name TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#;
/// Incremental migrations applied after the base schema.
@@ -753,38 +723,6 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
PRAGMA foreign_keys=ON;
"#,
),
(
14,
"users",
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BLOB NOT NULL,
token_prefix TEXT NOT NULL,
name TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#,
),
];
-145
View File
@@ -309,43 +309,6 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
Ok(())
}
// ==================== User management record types ====================
/// A registered user.
#[derive(Debug, Clone)]
pub struct UserRecord {
/// User identifier (string, matches existing `user_id` throughout the codebase).
pub id: String,
pub email: Option<String>,
pub display_name: String,
/// `active`, `suspended`, or `deactivated`.
pub status: String,
/// `admin` or `member`.
pub role: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
/// Who created/invited this user (nullable for bootstrap users).
pub created_by: Option<String>,
pub metadata: serde_json::Value,
}
/// An API token for authenticating requests (hash stored, never plaintext).
#[derive(Debug, Clone)]
pub struct ApiTokenRecord {
pub id: Uuid,
pub user_id: String,
/// Human label (e.g. "my-laptop", "ci-bot").
pub name: String,
/// First 8 hex chars of the plaintext token for display/identification.
pub token_prefix: String,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
/// Soft-revoke timestamp. Non-null means revoked.
pub revoked_at: Option<DateTime<Utc>>,
}
// ==================== Sub-traits ====================
//
// Each sub-trait groups related persistence methods. The `Database` supertrait
@@ -798,113 +761,6 @@ pub trait WorkspaceStore: Send + Sync {
}
}
#[async_trait]
pub trait UserStore: Send + Sync {
// ---- Users ----
/// Create a new user record.
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError>;
/// Get a user by their string id.
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError>;
/// Get a user by email address.
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError>;
/// List users, optionally filtered by status.
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError>;
/// Update a user's status (active/suspended/deactivated).
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError>;
/// Update a user's role (admin/member).
async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError>;
/// Update a user's display name and metadata.
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Record a login timestamp.
async fn record_login(&self, id: &str) -> Result<(), DatabaseError>;
// ---- API Tokens ----
/// Create a new API token. The `token_hash` is SHA-256 of the plaintext.
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError>;
/// List tokens for a user (never includes the hash).
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError>;
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError>;
/// Look up a token by hash, returning the token record and its owning user.
/// Only returns active (non-revoked, non-expired) tokens for active users.
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError>;
/// Update `last_used_at` for a token.
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
/// Check whether any user records exist (for first-run bootstrap detection).
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError>;
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<UserUsageStats>, DatabaseError>;
/// Lightweight per-user summary stats (job count, total cost, last active).
/// Used by the admin users list to show inline stats.
async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<UserSummaryStats>, DatabaseError>;
/// Create a user and their initial API token atomically.
/// If either operation fails, both are rolled back.
async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError>;
}
/// Per-user LLM usage statistics.
#[derive(Debug, Clone)]
pub struct UserUsageStats {
pub user_id: String,
pub model: String,
pub call_count: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub total_cost: Decimal,
}
/// Lightweight per-user summary for the admin users list.
#[derive(Debug, Clone)]
pub struct UserSummaryStats {
pub user_id: String,
/// Total agent jobs created by this user.
pub job_count: i64,
/// Total LLM spend across all jobs (all-time).
pub total_cost: Decimal,
/// Most recent activity (latest job or LLM call timestamp).
pub last_active_at: Option<DateTime<Utc>>,
}
/// Backend-agnostic database supertrait.
///
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
@@ -918,7 +774,6 @@ pub trait Database:
+ ToolFailureStore
+ SettingsStore
+ WorkspaceStore
+ UserStore
+ Send
+ Sync
{
+2 -114
View File
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::config::DatabaseConfig;
use crate::context::{ActionRecord, JobContext, JobState};
use crate::db::{
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore,
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore,
ToolFailureStore, WorkspaceStore,
};
use crate::error::{DatabaseError, WorkspaceError};
use crate::history::{
@@ -786,115 +786,3 @@ impl WorkspaceStore for PgBackend {
.await
}
}
// ==================== UserStore ====================
#[async_trait]
impl UserStore for PgBackend {
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
self.store.create_user(user).await
}
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
self.store.get_user(id).await
}
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
self.store.get_user_by_email(email).await
}
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
self.store.list_users(status).await
}
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
self.store.update_user_status(id, status).await
}
async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
self.store.update_user_role(id, role).await
}
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store
.update_user_profile(id, display_name, metadata)
.await
}
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
self.store.record_login(id).await
}
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
self.store
.create_api_token(user_id, name, token_hash, token_prefix, expires_at)
.await
}
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
self.store.list_api_tokens(user_id).await
}
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
self.store.revoke_api_token(token_id, user_id).await
}
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
self.store.authenticate_token(token_hash).await
}
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
self.store.record_token_usage(token_id).await
}
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
self.store.has_any_users().await
}
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
self.store.delete_user(id).await
}
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
self.store.user_usage_stats(user_id, since).await
}
async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
self.store.user_summary_stats(user_id).await
}
async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
self.store
.create_user_with_token(user, token_name, token_hash, token_prefix, expires_at)
.await
}
}
+7 -16
View File
@@ -2,8 +2,7 @@
//!
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
//! based on the configured [`SslMode`]. Uses `rustls` with system root
//! certificates, falling back to Mozilla's bundled roots via `webpki-roots`
//! when the system store is empty (common in minimal container images).
//! certificates — the same TLS stack that `reqwest` already uses for HTTP.
use deadpool_postgres::{Pool, Runtime};
use thiserror::Error;
@@ -20,15 +19,9 @@ pub enum CreatePoolError {
TlsConfig(#[from] rustls::Error),
}
/// Build a rustls-based TLS connector.
///
/// Tries the platform's native certificate store first. If that yields zero
/// certificates (slim container images, missing ca-certificates package),
/// falls back to Mozilla's root certificates bundled via `webpki-roots`.
/// Build a rustls-based TLS connector using the platform's root certificate store.
fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
let mut root_store = rustls::RootCertStore::empty();
// Try native certs first.
let native = rustls_native_certs::load_native_certs();
for e in &native.errors {
tracing::warn!("error loading system root certs: {e}");
@@ -38,14 +31,11 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
tracing::warn!("skipping invalid system root cert: {e}");
}
}
// Fall back to bundled Mozilla roots when the system store is empty.
if root_store.is_empty() {
tracing::info!("no system root certificates found, using bundled Mozilla roots");
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
tracing::error!("no system root certificates found -- TLS connections will fail");
}
// Pick the ring crypto provider (same one reqwest uses).
// `--all-features` brings in both aws-lc-rs and ring-backed rustls providers.
// Pick the same ring provider reqwest already uses so postgres TLS setup stays deterministic.
let config = rustls::ClientConfig::builder_with_provider(
rustls::crypto::ring::default_provider().into(),
)
@@ -58,7 +48,7 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
///
/// - `Disable` → plain TCP (no TLS)
/// - `Prefer` / `Require` → rustls with system or bundled root certificates
/// - `Prefer` / `Require` → rustls with system root certificates
///
/// **Note:** `Prefer` and `Require` currently behave identically — both
/// provide a TLS connector and will fail if the server rejects the TLS
@@ -91,6 +81,7 @@ mod tests {
fn create_pool_disable_mode() {
let mut config = deadpool_postgres::Config::new();
config.url = Some("postgres://localhost/test".to_string());
// Should succeed — pool is created lazily, no actual connection needed.
let pool = create_pool(&config, SslMode::Disable);
assert!(pool.is_ok());
}
+1 -649
View File
@@ -53,38 +53,6 @@ struct HostedOAuthFlowStart {
flow: crate::cli::oauth_defaults::PendingOAuthFlow,
}
#[derive(Debug, Default)]
struct SecretCleanupPlan {
base_secrets: HashSet<String>,
companion_secrets: HashMap<String, HashSet<String>>,
}
impl SecretCleanupPlan {
fn add_base_secret(&mut self, secret_name: impl AsRef<str>) {
self.base_secrets
.insert(secret_name.as_ref().to_lowercase());
}
fn add_companion_secret(
&mut self,
base_secret_name: impl AsRef<str>,
companion_secret_name: impl AsRef<str>,
) {
self.companion_secrets
.entry(base_secret_name.as_ref().to_lowercase())
.or_default()
.insert(companion_secret_name.as_ref().to_lowercase());
}
}
fn oauth_refresh_secret_name(secret_name: &str) -> String {
format!("{}_refresh_token", secret_name.to_lowercase())
}
fn oauth_scopes_secret_name(secret_name: &str) -> String {
format!("{}_scopes", secret_name.to_lowercase())
}
fn normalize_oauth_callback_path(path: &str) -> String {
let trimmed_path = path.trim_end_matches('/');
if trimmed_path.is_empty() {
@@ -1634,10 +1602,6 @@ impl ExtensionManager {
match kind {
ExtensionKind::McpServer => {
let cleanup_plan = self
.collect_secret_cleanup_plan(name, kind, user_id)
.await?;
// Unregister tools with this server's prefix
let tool_names: Vec<String> = self
.tool_registry
@@ -1659,9 +1623,6 @@ impl ExtensionManager {
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
.await;
Ok(format!(
"Removed MCP server '{}' and {} tool(s)",
name,
@@ -1669,10 +1630,6 @@ impl ExtensionManager {
))
}
ExtensionKind::WasmTool => {
let cleanup_plan = self
.collect_secret_cleanup_plan(name, kind, user_id)
.await?;
// Unregister from tool registry
self.tool_registry.unregister(name).await;
@@ -1717,16 +1674,9 @@ impl ExtensionManager {
let _ = tokio::fs::remove_file(&cap_path).await;
}
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
.await;
Ok(format!("Removed WASM tool '{}'", name))
}
ExtensionKind::WasmChannel => {
let cleanup_plan = self
.collect_secret_cleanup_plan(name, kind, user_id)
.await?;
// Remove from active set and persist
self.active_channel_names.write().await.remove(name);
self.persist_active_channels(user_id).await;
@@ -1752,9 +1702,6 @@ impl ExtensionManager {
let _ = tokio::fs::remove_file(&cap_path).await;
}
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
.await;
Ok(format!(
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
name
@@ -3052,258 +2999,6 @@ impl ExtensionManager {
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
}
async fn load_channel_capabilities(
&self,
name: &str,
) -> Option<crate::channels::wasm::ChannelCapabilitiesFile> {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
let cap_bytes = tokio::fs::read(&cap_path).await.ok()?;
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes).ok()
}
async fn collect_secret_cleanup_plan(
&self,
name: &str,
kind: ExtensionKind,
user_id: &str,
) -> Result<SecretCleanupPlan, ExtensionError> {
let mut plan = SecretCleanupPlan::default();
match kind {
ExtensionKind::WasmTool => {
if let Some(cap) = self.load_tool_capabilities(name).await {
for secret_name in Self::tool_secret_names(&cap) {
plan.add_base_secret(secret_name);
}
if let Some(auth) = cap.auth {
plan.add_base_secret(&auth.secret_name);
plan.add_companion_secret(
&auth.secret_name,
oauth_refresh_secret_name(&auth.secret_name),
);
plan.add_companion_secret(
&auth.secret_name,
oauth_scopes_secret_name(&auth.secret_name),
);
}
}
}
ExtensionKind::WasmChannel => {
if let Some(cap) = self.load_channel_capabilities(name).await {
for secret_name in Self::channel_secret_names(&cap) {
plan.add_base_secret(secret_name);
}
}
}
ExtensionKind::McpServer => {
let server = self
.get_mcp_server(name, user_id)
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
let token_secret_name = server.token_secret_name();
plan.add_base_secret(&token_secret_name);
plan.add_base_secret(server.client_id_secret_name());
// MCP OAuth can persist companion secrets through two paths:
// the MCP auth helper uses `mcp_<name>_refresh_token`, while the
// hosted gateway callback stores companions alongside the access
// token secret (`<token_secret>_refresh_token` / `_scopes`).
plan.add_companion_secret(&token_secret_name, server.refresh_token_secret_name());
plan.add_companion_secret(
&token_secret_name,
oauth_refresh_secret_name(&token_secret_name),
);
plan.add_companion_secret(
&token_secret_name,
oauth_scopes_secret_name(&token_secret_name),
);
}
ExtensionKind::ChannelRelay => {}
}
Ok(plan)
}
async fn cleanup_uninstalled_extension_secrets(&self, plan: SecretCleanupPlan, user_id: &str) {
let referenced_secrets = match self.collect_referenced_secret_names(user_id).await {
Ok(secret_names) => secret_names,
Err(error) => {
tracing::warn!(
user_id,
error,
"Failed to determine which secrets are still referenced; keeping secrets"
);
return;
}
};
for base_secret in &plan.base_secrets {
if referenced_secrets.contains(base_secret) {
continue;
}
self.delete_secret_best_effort(user_id, base_secret).await;
if let Some(companion_secrets) = plan.companion_secrets.get(base_secret) {
for companion_secret in companion_secrets {
if !referenced_secrets.contains(companion_secret) {
self.delete_secret_best_effort(user_id, companion_secret)
.await;
}
}
}
}
}
async fn delete_secret_best_effort(&self, user_id: &str, secret_name: &str) {
if let Err(error) = self.secrets.delete(user_id, secret_name).await {
tracing::warn!(
user_id,
secret_name,
error = %error,
"Failed to delete secret while uninstalling extension"
);
}
}
async fn collect_referenced_secret_names(
&self,
user_id: &str,
) -> Result<HashSet<String>, String> {
let mut referenced_secret_names = HashSet::new();
let tools = discover_tools(&self.wasm_tools_dir)
.await
.map_err(|e| format!("discover tools: {e}"))?;
for (tool_name, discovered_tool) in &tools {
let cap = self
.load_tool_capabilities(tool_name)
.await
.ok_or_else(|| {
let path = discovered_tool
.capabilities_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| format!("{} (missing)", tool_name));
format!("load tool capabilities for {tool_name}: {path}")
})?;
referenced_secret_names.extend(Self::tool_secret_names(&cap));
}
let channels = crate::channels::wasm::discover_channels(&self.wasm_channels_dir)
.await
.map_err(|e| format!("discover channels: {e}"))?;
for (channel_name, discovered_channel) in &channels {
let cap = self
.load_channel_capabilities(channel_name)
.await
.ok_or_else(|| {
let path = discovered_channel
.capabilities_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| format!("{} (missing)", channel_name));
format!("load channel capabilities for {channel_name}: {path}")
})?;
referenced_secret_names.extend(Self::channel_secret_names(&cap));
}
let mcp_servers = self
.load_mcp_servers(user_id)
.await
.map_err(|e| format!("load MCP servers: {e}"))?;
for server in &mcp_servers.servers {
referenced_secret_names.extend(Self::mcp_server_secret_names(server));
}
Ok(referenced_secret_names)
}
fn tool_secret_names(cap: &crate::tools::wasm::CapabilitiesFile) -> HashSet<String> {
let mut names = HashSet::new();
if let Some(auth) = &cap.auth {
names.insert(auth.secret_name.to_lowercase());
}
if let Some(setup) = &cap.setup {
names.extend(
setup
.required_secrets
.iter()
.map(|secret| secret.name.to_lowercase()),
);
}
if let Some(http) = &cap.http {
names.extend(
http.credentials
.values()
.map(|credential| credential.secret_name.to_lowercase()),
);
}
if let Some(webhook) = &cap.webhook {
if let Some(secret_name) = &webhook.secret_name {
names.insert(secret_name.to_lowercase());
}
if let Some(secret_name) = &webhook.signature_key_secret_name {
names.insert(secret_name.to_lowercase());
}
if let Some(secret_name) = &webhook.hmac_secret_name {
names.insert(secret_name.to_lowercase());
}
}
names
}
fn channel_secret_names(
cap: &crate::channels::wasm::ChannelCapabilitiesFile,
) -> HashSet<String> {
let mut names: HashSet<String> = cap
.setup
.required_secrets
.iter()
.map(|secret| secret.name.to_lowercase())
.collect();
if let Some(http) = cap.capabilities.tool.http.as_ref() {
names.extend(
http.credentials
.values()
.map(|credential| credential.secret_name.to_lowercase()),
);
}
if let Some(webhook) = cap
.capabilities
.channel
.as_ref()
.and_then(|channel| channel.webhook.as_ref())
{
if webhook.secret_header.is_some() || webhook.secret_name.is_some() {
names.insert(cap.webhook_secret_name().to_lowercase());
}
if let Some(secret_name) = cap.signature_key_secret_name() {
names.insert(secret_name.to_lowercase());
}
if let Some(secret_name) = cap.hmac_secret_name() {
names.insert(secret_name.to_lowercase());
}
}
names
}
fn mcp_server_secret_names(server: &McpServerConfig) -> HashSet<String> {
[
server.token_secret_name().to_lowercase(),
server.client_id_secret_name().to_lowercase(),
]
.into_iter()
.collect()
}
/// Collect merged OAuth scopes from all installed tools sharing the same secret_name.
///
/// When multiple tools share an OAuth provider (e.g., google-calendar and google-drive
@@ -6338,8 +6033,6 @@ mod tests {
ExtensionError, ExtensionKind, ExtensionSource, InstallResult, VerificationChallenge,
};
use crate::pairing::PairingStore;
use crate::secrets::CreateSecretParams;
use crate::tools::mcp::McpServerConfig;
fn require(condition: bool, message: impl Into<String>) -> Result<(), String> {
if condition {
@@ -6660,38 +6353,6 @@ mod tests {
tools_dir
}
fn write_test_channel(
dir: &std::path::Path,
name: &str,
capabilities_json: &str,
) -> std::path::PathBuf {
let channels_dir = dir.join("channels");
std::fs::create_dir_all(&channels_dir).expect("channels dir");
std::fs::write(
channels_dir.join(format!("{name}.wasm")),
b"not-a-real-wasm",
)
.expect("wasm");
std::fs::write(
channels_dir.join(format!("{name}.capabilities.json")),
capabilities_json,
)
.expect("capabilities");
channels_dir
}
async fn store_test_secret(
manager: &crate::extensions::manager::ExtensionManager,
name: &str,
value: &str,
) {
manager
.secrets
.create("test", CreateSecretParams::new(name, value))
.await
.expect("store secret");
}
#[test]
fn test_setting_value_is_present() {
assert!(
@@ -7762,13 +7423,7 @@ mod tests {
// Regression: remove() only checked channel_runtime for shutdown, missing
// relay-only mode where only relay_channel_manager is set.
let dir = tempfile::tempdir().expect("temp dir");
let (store, _db_dir) = make_test_store().await;
let mgr = make_test_manager_with_dirs(
None,
dir.path().join("tools"),
dir.path().join("channels"),
Some(store),
);
let mgr = make_test_manager(None, dir.path().to_path_buf());
// Set up relay channel manager with a stub channel
let cm = Arc::new(crate::channels::ChannelManager::new());
@@ -7795,8 +7450,6 @@ mod tests {
.await
.expect("store team_id");
}
store_test_secret(&mgr, "relay:slack-relay:oauth_state", "nonce").await;
store_test_secret(&mgr, "relay:slack-relay:stream_token", "legacy-token").await;
// Verify channel exists before removal
assert!(cm.get_channel("slack-relay").await.is_some());
@@ -7825,30 +7478,6 @@ mod tests {
cm.get_channel("slack-relay").await.is_none(),
"relay channel should be removed from the channel manager"
);
assert!(
!mgr.secrets
.exists("test", "relay:slack-relay:oauth_state")
.await
.expect("oauth state exists query"),
"relay oauth_state secret should be removed"
);
assert!(
!mgr.secrets
.exists("test", "relay:slack-relay:stream_token")
.await
.expect("stream token exists query"),
"relay legacy stream token should be removed"
);
assert_eq!(
mgr.store
.as_ref()
.expect("store")
.get_setting("test", "relay:slack-relay:team_id")
.await
.expect("team_id query"),
None,
"relay team_id setting should be removed"
);
}
#[tokio::test]
@@ -7956,185 +7585,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_remove_wasm_tool_deletes_unique_secrets() {
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = write_test_tool(
dir.path(),
"github",
r#"{
"name": "github",
"auth": { "secret_name": "github_token" },
"setup": {
"required_secrets": [
{ "name": "github_client_secret", "prompt": "GitHub client secret for testing cleanup behavior." }
]
},
"http": {
"credentials": {
"service_token": {
"secret_name": "github_service_token",
"location": { "type": "bearer" }
}
}
},
"webhook": {
"hmac_secret_name": "github_webhook_secret"
}
}"#,
);
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
store_test_secret(&mgr, "github_token", "access-token").await;
store_test_secret(&mgr, "github_token_refresh_token", "refresh-token").await;
store_test_secret(&mgr, "github_token_scopes", "repo workflow").await;
store_test_secret(&mgr, "github_client_secret", "client-secret").await;
store_test_secret(&mgr, "github_service_token", "service-token").await;
store_test_secret(&mgr, "github_webhook_secret", "webhook-secret").await;
mgr.remove("github", "test")
.await
.expect("remove should succeed");
for secret_name in [
"github_token",
"github_token_refresh_token",
"github_token_scopes",
"github_client_secret",
"github_service_token",
"github_webhook_secret",
] {
assert!(
!mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"secret {secret_name} should be deleted"
);
}
}
#[tokio::test]
async fn test_remove_wasm_tool_keeps_secrets_when_other_tool_capabilities_missing() {
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = write_test_tool(
dir.path(),
"github",
r#"{
"name": "github",
"auth": { "secret_name": "shared_token" }
}"#,
);
std::fs::write(tools_dir.join("broken.wasm"), b"fake-tool").expect("write tool");
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
store_test_secret(&mgr, "shared_token", "access-token").await;
store_test_secret(&mgr, "shared_token_refresh_token", "refresh-token").await;
store_test_secret(&mgr, "shared_token_scopes", "repo").await;
mgr.remove("github", "test")
.await
.expect("remove should succeed");
for secret_name in [
"shared_token",
"shared_token_refresh_token",
"shared_token_scopes",
] {
assert!(
mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"secret {secret_name} should be retained when reference detection is uncertain"
);
}
}
#[tokio::test]
async fn test_remove_wasm_tool_keeps_shared_secrets_until_last_extension() {
let dir = tempfile::tempdir().expect("temp dir");
write_test_tool(
dir.path(),
"google-calendar",
r#"{
"name": "google-calendar",
"auth": { "secret_name": "google_oauth_token" },
"setup": {
"required_secrets": [
{ "name": "google_oauth_client_id", "prompt": "Google OAuth client id for cleanup testing." },
{ "name": "google_oauth_client_secret", "prompt": "Google OAuth client secret for cleanup testing." }
]
}
}"#,
);
let tools_dir = write_test_tool(
dir.path(),
"google-drive",
r#"{
"name": "google-drive",
"auth": { "secret_name": "google_oauth_token" },
"setup": {
"required_secrets": [
{ "name": "google_oauth_client_id", "prompt": "Google OAuth client id for cleanup testing." },
{ "name": "google_oauth_client_secret", "prompt": "Google OAuth client secret for cleanup testing." }
]
}
}"#,
);
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
for (secret_name, value) in [
("google_oauth_token", "access-token"),
("google_oauth_token_refresh_token", "refresh-token"),
("google_oauth_token_scopes", "calendar drive"),
("google_oauth_client_id", "client-id"),
("google_oauth_client_secret", "client-secret"),
] {
store_test_secret(&mgr, secret_name, value).await;
}
mgr.remove("google-calendar", "test")
.await
.expect("first remove should succeed");
for secret_name in [
"google_oauth_token",
"google_oauth_token_refresh_token",
"google_oauth_token_scopes",
"google_oauth_client_id",
"google_oauth_client_secret",
] {
assert!(
mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"shared secret {secret_name} should remain while google-drive is still installed"
);
}
mgr.remove("google-drive", "test")
.await
.expect("second remove should succeed");
for secret_name in [
"google_oauth_token",
"google_oauth_token_refresh_token",
"google_oauth_token_scopes",
"google_oauth_client_id",
"google_oauth_client_secret",
] {
assert!(
!mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"shared secret {secret_name} should be deleted after the last tool is removed"
);
}
}
#[tokio::test]
async fn test_remove_wasm_channel_clears_activation_error_and_deletes_files() {
let dir = tempfile::tempdir().expect("temp dir");
@@ -8169,104 +7619,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_remove_wasm_channel_deletes_setup_secrets() {
let dir = tempfile::tempdir().expect("temp dir");
let channels_dir = write_test_channel(
dir.path(),
"telegram",
r#"{
"type": "channel",
"name": "telegram",
"setup": {
"required_secrets": [
{
"name": "telegram_bot_token",
"prompt": "Telegram bot token used to verify uninstall cleanup behavior."
}
]
},
"capabilities": {
"http": {
"credentials": {
"tenant_token": {
"secret_name": "telegram_service_token",
"location": { "type": "bearer" }
}
}
},
"channel": {
"webhook": {
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
"secret_name": "telegram_webhook_secret"
}
}
}
}"#,
);
let mgr = make_test_manager_with_dirs(None, dir.path().join("tools"), channels_dir, None);
store_test_secret(&mgr, "telegram_bot_token", "123:telegram-token").await;
store_test_secret(&mgr, "telegram_service_token", "tenant-service-token").await;
store_test_secret(&mgr, "telegram_webhook_secret", "webhook-secret").await;
mgr.remove("telegram", "test")
.await
.expect("remove should succeed");
for secret_name in [
"telegram_bot_token",
"telegram_service_token",
"telegram_webhook_secret",
] {
assert!(
!mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"channel secret {secret_name} should be deleted"
);
}
}
#[tokio::test]
async fn test_remove_mcp_server_deletes_stored_secrets() {
let dir = tempfile::tempdir().expect("temp dir");
let (store, _db_dir) = make_test_store().await;
let mgr = make_test_manager_with_dirs(
None,
dir.path().join("tools"),
dir.path().join("channels"),
Some(Arc::clone(&store)),
);
let server = McpServerConfig::new("notion", "https://example.com/mcp");
mgr.add_mcp_server(server.clone(), "test")
.await
.expect("add mcp server");
store_test_secret(&mgr, &server.token_secret_name(), "access-token").await;
store_test_secret(&mgr, &server.refresh_token_secret_name(), "refresh-token").await;
store_test_secret(&mgr, &server.client_id_secret_name(), "client-id").await;
mgr.remove("notion", "test")
.await
.expect("remove should succeed");
for secret_name in [
server.token_secret_name(),
server.refresh_token_secret_name(),
server.client_id_secret_name(),
] {
assert!(
!mgr.secrets
.exists("test", &secret_name)
.await
.expect("exists query"),
"MCP secret {secret_name} should be deleted"
);
}
}
#[test]
fn test_sanitize_url_with_query_params() {
let url = "https://api.example.com/path?api_key=secret123&token=abc";
-540
View File
@@ -2279,546 +2279,6 @@ impl Store {
}
}
// ==================== Users / API Tokens / Invitations ====================
#[cfg(feature = "postgres")]
use crate::db::{ApiTokenRecord, UserRecord};
#[cfg(feature = "postgres")]
impl Store {
/// Create a new user record.
pub async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
Ok(())
}
/// Get a user by their string id.
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// Get a user by email address.
pub async fn get_user_by_email(
&self,
email: &str,
) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// List users, optionally filtered by status.
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = match status {
Some(s) => {
conn.query(
"SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
&[&s],
)
.await?
}
None => {
conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
.await?
}
};
Ok(rows.iter().map(row_to_user).collect())
}
/// Update a user's status.
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
&[&status, &id],
)
.await?;
Ok(())
}
/// Update a user's role (admin/member).
pub async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2",
&[&role, &id],
)
.await?;
Ok(())
}
/// Update a user's display name and metadata.
pub async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
&[&display_name, metadata, &id],
)
.await?;
Ok(())
}
/// Record a login timestamp for a user.
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
&[&id],
)
.await?;
Ok(())
}
/// Create a new API token.
pub async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
let now = Utc::now();
conn.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user_id,
&token_hash.as_slice(),
&token_prefix,
&name,
&expires_at,
&now,
],
)
.await?;
Ok(ApiTokenRecord {
id,
user_id: user_id.to_string(),
name: name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// Create a user and their initial API token atomically in a single transaction.
pub async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn.transaction().await?;
tx.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
let id = Uuid::new_v4();
let now = Utc::now();
tx.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user.id,
&token_hash.as_slice(),
&token_prefix,
&token_name,
&expires_at,
&now,
],
)
.await?;
tx.commit().await?;
Ok(ApiTokenRecord {
id,
user_id: user.id.clone(),
name: token_name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// List tokens for a user.
pub async fn list_api_tokens(
&self,
user_id: &str,
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
"#,
&[&user_id],
)
.await?;
Ok(rows.iter().map(row_to_api_token).collect())
}
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
pub async fn revoke_api_token(
&self,
token_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute(
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
&[&token_id, &user_id],
)
.await?;
Ok(count > 0)
}
/// Authenticate a token by hash. Returns the token record and its owning user
/// if the token is active (non-revoked, non-expired) and the user is active.
pub async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at,
u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
FROM api_tokens t
JOIN users u ON t.user_id = u.id
WHERE t.token_hash = $1
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
AND u.status = 'active'
"#,
&[&token_hash.as_slice()],
)
.await?;
Ok(row.map(|r| {
let token = ApiTokenRecord {
id: r.get("id"),
user_id: r.get("user_id"),
name: r.get("name"),
token_prefix: r.get("token_prefix"),
expires_at: r.get("expires_at"),
last_used_at: r.get("last_used_at"),
created_at: r.get("created_at"),
revoked_at: r.get("revoked_at"),
};
let user = UserRecord {
id: r.get("u_id"),
email: r.get("email"),
display_name: r.get("display_name"),
status: r.get("status"),
role: r.get("role"),
created_at: r.get("u_created_at"),
updated_at: r.get("updated_at"),
last_login_at: r.get("last_login_at"),
created_by: r.get("created_by"),
metadata: r.get("metadata"),
};
(token, user)
}))
}
/// Update `last_used_at` for a token.
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
&[&token_id],
)
.await?;
Ok(())
}
/// Check whether any user records exist.
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
&[],
)
.await?;
Ok(row.get("has_users"))
}
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
pub async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn
.transaction()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Delete from child tables first to avoid FK violations.
// job_events must come before agent_jobs (FK without CASCADE).
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots.
// conversations cascades to conversation_messages.
// memory_documents cascades to memory_chunks.
// routines cascades to routine_runs.
// api_tokens cascade automatically via FK on users.
for table in &[
"settings",
"heartbeat_state",
"tool_rate_limit_state",
"secret_usage_log",
"leak_detection_events",
"secrets",
"wasm_tools",
"routines",
"memory_documents",
"conversations",
] {
tx.execute(&format!("DELETE FROM {table} WHERE user_id = $1"), &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
}
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
tx.execute(
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = $1)",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.execute("DELETE FROM agent_jobs WHERE user_id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Nullify self-referencing created_by before deleting the user
tx.execute(
"UPDATE users SET created_by = NULL WHERE created_by = $1",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// api_tokens cascade automatically via FK
let result = tx
.execute("DELETE FROM users WHERE id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.commit()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(result > 0)
}
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
pub async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT COALESCE(j.user_id, c.user_id) as user_id,
l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE l.created_at >= $1
AND COALESCE(j.user_id, c.user_id) = $2
GROUP BY COALESCE(j.user_id, c.user_id), l.model
ORDER BY total_cost DESC
"#,
&[&since, &uid],
)
.await?
} else {
conn.query(
r#"
SELECT COALESCE(j.user_id, c.user_id) as user_id,
l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE l.created_at >= $1
GROUP BY COALESCE(j.user_id, c.user_id), l.model
ORDER BY total_cost DESC
"#,
&[&since],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserUsageStats {
user_id: row.get("user_id"),
model: row.get("model"),
call_count: row.get("call_count"),
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
total_cost: row.get("total_cost"),
});
}
Ok(stats)
}
/// Lightweight per-user summary stats (job count, total cost, last active).
///
/// Aggregates from `llm_calls`, resolving user_id via either `agent_jobs`
/// (for background job calls) or `conversations` (for chat calls where
/// `job_id` is NULL).
pub async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT
COALESCE(j.user_id, c.user_id) AS user_id,
COUNT(DISTINCT j.id) AS job_count,
COALESCE(SUM(l.cost), 0) AS total_cost,
MAX(l.created_at) AS last_active_at
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE COALESCE(j.user_id, c.user_id) = $1
GROUP BY COALESCE(j.user_id, c.user_id)
"#,
&[&uid],
)
.await?
} else {
conn.query(
r#"
SELECT
COALESCE(j.user_id, c.user_id) AS user_id,
COUNT(DISTINCT j.id) AS job_count,
COALESCE(SUM(l.cost), 0) AS total_cost,
MAX(l.created_at) AS last_active_at
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
GROUP BY COALESCE(j.user_id, c.user_id)
"#,
&[],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserSummaryStats {
user_id: row.get("user_id"),
job_count: row.get("job_count"),
total_cost: row.get("total_cost"),
last_active_at: row.get("last_active_at"),
});
}
Ok(stats)
}
}
#[cfg(feature = "postgres")]
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
UserRecord {
id: row.get("id"),
email: row.get("email"),
display_name: row.get("display_name"),
status: row.get("status"),
role: row.get("role"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
last_login_at: row.get("last_login_at"),
created_by: row.get("created_by"),
metadata: row.get("metadata"),
}
}
#[cfg(feature = "postgres")]
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
ApiTokenRecord {
id: row.get("id"),
user_id: row.get("user_id"),
name: row.get("name"),
token_prefix: row.get("token_prefix"),
expires_at: row.get("expires_at"),
last_used_at: row.get("last_used_at"),
created_at: row.get("created_at"),
revoked_at: row.get("revoked_at"),
}
}
#[cfg(test)]
mod tests {
use super::*;
+5 -55
View File
@@ -601,14 +601,11 @@ fn build_rig_request(
/// Inject a per-request model override into the rig request's `additional_params`.
///
/// Rig-core bakes the model name at construction time inside each provider's
/// `CompletionModel` implementation. This helper inserts a top-level `"model"`
/// key into `additional_params`, which rig-core flattens into the provider's
/// request payload via `#[serde(flatten)]`.
///
/// Whether the override takes effect depends on the downstream API server's
/// handling of duplicate JSON keys (most Python/Go servers use last-key-wins,
/// but this is not guaranteed by the JSON spec). The `effective_model_name()`
/// trait method should be consulted to determine the model actually used.
/// `CompletionModel` implementation. The actual HTTP request body includes a
/// `model` field set by the provider. Rig-core's `#[serde(flatten)]` on
/// `additional_params` emits these fields AFTER the provider's own fields.
/// Most API servers (Python, Go) use last-key-wins when deserializing
/// duplicate JSON keys, so the injected `model` value takes effect.
fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
let Some(model) = model_override else {
return;
@@ -1518,51 +1515,4 @@ mod tests {
"different raw IDs should produce different hashed IDs"
);
}
fn make_rig_request(additional_params: Option<serde_json::Value>) -> RigRequest {
RigRequest {
preamble: None,
chat_history: OneOrMany::one(RigMessage::user("test")),
documents: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params,
}
}
#[test]
fn test_inject_model_override_creates_params_when_none() {
let mut req = make_rig_request(None);
inject_model_override(&mut req, Some("test-model"));
let params = req
.additional_params
.expect("additional_params should be Some");
assert_eq!(params, serde_json::json!({ "model": "test-model" }));
}
#[test]
fn test_inject_model_override_preserves_existing_params() {
let mut req = make_rig_request(Some(serde_json::json!({
"cache_control": { "type": "ephemeral" },
})));
inject_model_override(&mut req, Some("override-model"));
let params = req.additional_params.expect("should remain Some");
let obj = params.as_object().expect("should be object");
assert_eq!(
obj.get("cache_control"),
Some(&serde_json::json!({ "type": "ephemeral" }))
);
assert_eq!(obj.get("model"), Some(&serde_json::json!("override-model")));
}
#[test]
fn test_inject_model_override_noop_when_none() {
let mut req = make_rig_request(None);
inject_model_override(&mut req, None);
assert!(req.additional_params.is_none());
}
}
+27 -50
View File
@@ -591,7 +591,27 @@ async fn async_main() -> anyhow::Result<()> {
let mut gateway_url: Option<String> = None;
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone());
// Build multi-user auth state if user_tokens is configured, else single-user.
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
let tokens = user_tokens
.iter()
.map(|(token, cfg)| {
(
token.clone(),
UserIdentity {
user_id: cfg.user_id.clone(),
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
},
)
})
.collect();
let auth = MultiAuthState::multi(tokens);
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
} else {
GatewayChannel::new(gw_config.clone())
};
gw = gw.with_owner_scope(config.owner_id.clone());
gw = gw.with_llm_provider(Arc::clone(&components.llm));
if let Some(ref ws) = components.workspace {
gw = gw.with_workspace(Arc::clone(ws));
@@ -630,54 +650,6 @@ async fn async_main() -> anyhow::Result<()> {
}
if let Some(ref d) = components.db {
gw = gw.with_store(Arc::clone(d));
gw = gw.with_db_auth(Arc::clone(d));
if let Some(ref ss) = components.secrets_store {
gw = gw.with_secrets_store(Arc::clone(ss));
}
// Bootstrap: create the first admin user from single-user config
// so the owner appears in the Users admin panel immediately.
if let Ok(false) = d.has_any_users().await {
let now = chrono::Utc::now();
let user = ironclaw::db::UserRecord {
id: config.owner_id.clone(),
email: None,
display_name: config.owner_id.clone(),
status: "active".to_string(),
role: "admin".to_string(),
created_at: now,
updated_at: now,
last_login_at: None,
created_by: None,
metadata: serde_json::json!({"source": "bootstrap"}),
};
// Create admin user + bootstrap token atomically.
let auth_token = gw.auth_token();
if auth_token.is_empty() {
if let Err(e) = d.create_user(&user).await {
tracing::warn!("Failed to bootstrap admin user: {}", e);
}
} else {
use ironclaw::channels::web::auth::hash_token;
let hash = hash_token(auth_token);
let prefix = if auth_token.len() >= 8 {
&auth_token[..8]
} else {
auth_token
};
if let Err(e) = d
.create_user_with_token(&user, "bootstrap", &hash, prefix, None)
.await
{
tracing::warn!("Failed to bootstrap admin user: {}", e);
} else {
tracing::info!(
user_id = config.owner_id,
"Bootstrapped admin user from gateway config"
);
}
}
}
}
if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm));
@@ -819,7 +791,12 @@ async fn async_main() -> anyhow::Result<()> {
.await;
// Default user ID for extension operations (single-user mode).
let ext_user_id = config.owner_id.clone();
let ext_user_id = config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string());
// Wire up channel runtime for hot-activation of WASM channels.
if let Some(ref ext_mgr) = components.extension_manager
+5
View File
@@ -269,6 +269,10 @@ pub struct ChannelSettings {
#[serde(default)]
pub gateway_auth_token: Option<String>,
/// Web gateway user ID.
#[serde(default)]
pub gateway_user_id: Option<String>,
/// Whether the CLI channel is enabled.
#[serde(default = "default_true")]
pub cli_enabled: bool,
@@ -338,6 +342,7 @@ impl Default for ChannelSettings {
gateway_host: None,
gateway_port: None,
gateway_auth_token: None,
gateway_user_id: None,
cli_enabled: true,
signal_enabled: false,
signal_http_url: None,
+1 -49
View File
@@ -209,13 +209,6 @@ impl TenantScope {
.await
}
// === LLM call recording ===
/// Record an LLM call to the database for persistent usage tracking.
pub async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError> {
self.inner.record_llm_call(record).await
}
// === Settings ===
pub async fn get_setting(&self, key: &str) -> Result<Option<serde_json::Value>, DatabaseError> {
@@ -337,62 +330,35 @@ impl TenantScope {
/// Add a message to a conversation owned by this tenant.
///
/// Returns `NotFound` if the conversation does not belong to this user.
/// Verifies the conversation belongs to this user before adding.
pub async fn add_conversation_message(
&self,
conversation_id: Uuid,
role: &str,
content: &str,
) -> Result<Uuid, DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner
.add_conversation_message(conversation_id, role, content)
.await
}
/// Touch a conversation timestamp. Returns `NotFound` if not owned by this user.
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner.touch_conversation(id).await
}
/// List messages in a conversation. Returns `NotFound` if not owned by this user.
pub async fn list_conversation_messages(
&self,
conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner.list_conversation_messages(conversation_id).await
}
/// Paginated message listing. Returns `NotFound` if not owned by this user.
pub async fn list_conversation_messages_paginated(
&self,
conversation_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner
.list_conversation_messages_paginated(conversation_id, before, limit)
.await
@@ -408,35 +374,21 @@ impl TenantScope {
.await
}
/// Update metadata on a conversation. Returns `NotFound` if not owned by this user.
pub async fn update_conversation_metadata_field(
&self,
id: Uuid,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner
.update_conversation_metadata_field(id, key, value)
.await
}
/// Get conversation metadata. Returns `NotFound` if not owned by this user.
pub async fn get_conversation_metadata(
&self,
id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner.get_conversation_metadata(id).await
}
}
+2 -4
View File
@@ -124,10 +124,8 @@ impl WasmToolLoader {
let wasm_bytes = fs::read(wasm_path).await?;
// Read capabilities (optional) and extract OAuth refresh config
// and tool description. Parameter schema is NOT read from the
// capabilities file — it is auto-derived from the WASM module's
// schema() export at prepare time (see WasmToolSchemas::compact_schema),
// so no schema override is needed here.
// and tool description. Parameter schema is auto-derived from the
// WASM module's schema() export (see WasmToolSchemas::compact_schema).
let (capabilities, oauth_refresh, description) = if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
+5 -92
View File
@@ -759,31 +759,14 @@ impl WasmToolSchemas {
}
let kept: serde_json::Map<String, serde_json::Value> = all_properties
.iter()
.into_iter()
.filter(|(name, prop)| {
required.contains(name.as_str())
|| prop.get("enum").is_some()
|| prop.get("const").is_some()
required.contains(name) || prop.get("enum").is_some() || prop.get("const").is_some()
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if kept.is_empty() {
// When the schema has typed properties but none survived the
// required/enum filter, include all typed properties so the LLM
// sees meaningful parameter hints instead of permissive `{}`.
let typed: serde_json::Map<String, serde_json::Value> = all_properties
.into_iter()
.filter(|(_, prop)| schema_is_typed_property(prop))
.collect();
if typed.is_empty() {
return Self::permissive_schema();
}
return serde_json::json!({
"type": "object",
"properties": typed,
"additionalProperties": true,
});
return Self::permissive_schema();
}
let kept_required: Vec<serde_json::Value> = required
@@ -2008,58 +1991,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_typed_schema_without_required_is_advertised() {
// Regression test for #1303: when a WASM tool exports a typed schema
// with no required/enum fields, the advertised schema should still
// contain the typed properties instead of falling back to permissive {}.
let discovery_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" }
}
});
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
let prepared = runtime
.prepare("typed_search", b"\0asm\x0d\0\x01\0", None)
.await
.unwrap();
let mut wrapper =
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
wrapper.description = "Typed search tool".to_string();
let advertised = wrapper.parameters_schema();
let props = advertised["properties"].as_object().unwrap();
// Both typed properties should be preserved in the advertised schema
assert!(
props.contains_key("query"),
"advertised schema should contain 'query' property"
);
assert!(
props.contains_key("limit"),
"advertised schema should contain 'limit' property"
);
assert_eq!(props.len(), 2);
// The schema should NOT be permissive
assert!(
!super::WasmToolSchemas::is_permissive_schema(&advertised),
"advertised schema should not be permissive when typed properties exist"
);
// No tool_info hint needed since typed properties are visible
let schema = wrapper.schema();
assert!(
!schema.description.contains("tool_info"),
"description should not contain tool_info hint: {}",
schema.description
);
}
#[test]
fn test_compact_schema_keeps_required_and_enum_properties() {
let schema = serde_json::json!({
@@ -2097,8 +2028,8 @@ mod tests {
}
#[test]
fn test_compact_schema_preserves_typed_properties_when_no_required() {
// No required, no enum, but typed properties → keep all typed props
fn test_compact_schema_falls_back_to_permissive_when_empty() {
// No required, no enum → permissive fallback
let schema = serde_json::json!({
"type": "object",
"properties": {
@@ -2107,24 +2038,6 @@ mod tests {
}
});
let compacted = super::WasmToolSchemas::compact_schema(&schema);
let props = compacted["properties"].as_object().unwrap();
assert_eq!(props.len(), 2);
assert!(props.contains_key("query"));
assert!(props.contains_key("limit"));
assert_eq!(compacted["additionalProperties"], true);
}
#[test]
fn test_compact_schema_falls_back_to_permissive_when_no_typed_properties() {
// Properties with no type info → permissive fallback
let schema = serde_json::json!({
"type": "object",
"properties": {
"data": {}
}
});
let compacted = super::WasmToolSchemas::compact_schema(&schema);
assert!(compacted["properties"].as_object().unwrap().is_empty());
}
+4
View File
@@ -428,8 +428,10 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
});
c
}
@@ -440,8 +442,10 @@ mod tests {
host: host.to_string(),
port,
auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
});
c
}
-2
View File
@@ -53,7 +53,6 @@ HEADED=1 pytest scenarios/
| `test_skills.py` | Skills tab UI visibility, ClawHub search (skipped if registry unreachable), install + remove lifecycle |
| `test_sse_reconnect.py` | SSE reconnects after programmatic `eventSource.close()` + `connectSSE()`; history is reloaded after reconnect |
| `test_tool_approval.py` | Approval card appears, buttons disable on approve/deny, parameters toggle via `page.evaluate("showApproval(...)")`; the waiting-approval regression uses a real HTTP tool call |
| `test_extension_uninstall_cleanup.py` | Real install/setup/remove coverage for WASM tools, WASM channels, OAuth-backed shared Google tools, and MCP servers; verifies uninstall deletes stored secrets from the libSQL `secrets` table while preserving shared credentials until the last referencing extension is removed |
| `test_oauth_refresh.py` | Hosted Gmail OAuth regression: complete setup via `/oauth/callback`, expire the stored access token in libSQL, trigger a real `gmail` tool call through `/api/chat/send`, and verify refresh goes through the mock `/oauth/refresh` proxy without forwarding `client_secret` |
## `helpers.py`
@@ -78,7 +77,6 @@ All fixtures are defined in `tests/e2e/conftest.py`. Running `pytest scenarios/`
| `mock_llm_server` | Starts `mock_llm.py --port 0`, reads the assigned port from stdout, waits for `/v1/models` to return 200. Yields the base URL. |
| `ironclaw_server` | Starts the ironclaw binary with a minimal env (see below), waits for `/api/health` (timeout 60s). Yields the base URL. On teardown sends **SIGINT** (not SIGTERM) so the tokio ctrl_c handler triggers a graceful shutdown and LLVM coverage data is flushed. |
| `hosted_oauth_refresh_server` | Starts a second ironclaw instance with a dedicated libSQL DB and `GOOGLE_OAUTH_CLIENT_ID=hosted-google-client-id`, while still pointing `IRONCLAW_OAUTH_EXCHANGE_URL` at `mock_llm.py`. Yields a dict with `base_url`, `db_path`, `gateway_user_id`, and `mock_llm_url` for the hosted refresh regression scenario. |
| `extension_cleanup_server` | Starts an isolated ironclaw instance with its own temp DB/home/WASM dirs, `SECRETS_MASTER_KEY`, and hosted-style OAuth env so uninstall-cleanup scenarios can inspect the `secrets` table without interfering with the shared E2E server state. |
| `browser` | Launches a single Chromium instance (headless by default; set `HEADED=1` for headed). Shared across all tests. |
### Function-scoped fixtures
+4 -109
View File
@@ -271,6 +271,7 @@ async def ironclaw_server(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-web-sender",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -370,6 +371,7 @@ async def hosted_oauth_refresh_server(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -409,6 +411,7 @@ async def hosted_oauth_refresh_server(
yield {
"base_url": base_url,
"db_path": db_path,
"gateway_user_id": OWNER_SCOPE_ID,
"mock_llm_url": mock_llm_server,
}
except TimeoutError:
@@ -443,115 +446,6 @@ async def hosted_oauth_refresh_server(
home_tmpdir.cleanup()
@pytest.fixture(scope="session")
async def extension_cleanup_server(
ironclaw_binary,
mock_llm_server,
):
"""Start an isolated ironclaw instance for uninstall secret cleanup E2E tests."""
reserved = _reserve_loopback_sockets(2)
db_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-db-")
home_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-home-")
tools_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-tools-")
channels_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-channels-")
try:
gateway_port = reserved[0].getsockname()[1]
http_port = reserved[1].getsockname()[1]
for sock in reserved:
if sock.fileno() != -1:
sock.close()
db_path = os.path.join(db_tmpdir.name, "extension-cleanup.db")
home_dir = home_tmpdir.name
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": home_dir,
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
"RUST_LOG": "ironclaw=info",
"RUST_BACKTRACE": "1",
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
"GATEWAY_ENABLED": "true",
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
"CLI_ENABLED": "false",
"LLM_BACKEND": "openai_compatible",
"LLM_BASE_URL": mock_llm_server,
"LLM_MODEL": "mock-model",
"DATABASE_BACKEND": "libsql",
"LIBSQL_PATH": db_path,
"SECRETS_MASTER_KEY": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"SANDBOX_ENABLED": "false",
"SKILLS_ENABLED": "true",
"ROUTINES_ENABLED": "true",
"HEARTBEAT_ENABLED": "false",
"EMBEDDING_ENABLED": "false",
"WASM_ENABLED": "true",
"WASM_TOOLS_DIR": tools_tmpdir.name,
"WASM_CHANNELS_DIR": channels_tmpdir.name,
"ONBOARD_COMPLETED": "true",
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
"GOOGLE_OAUTH_CLIENT_ID": "hosted-google-client-id",
}
_forward_coverage_env(env)
proc = await asyncio.create_subprocess_exec(
ironclaw_binary, "--no-onboard",
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
startup_kill_attempted = False
base_url = f"http://127.0.0.1:{gateway_port}"
try:
await wait_for_ready(f"{base_url}/api/health", timeout=60)
yield {
"base_url": base_url,
"db_path": db_path,
"gateway_user_id": OWNER_SCOPE_ID,
"mock_llm_url": mock_llm_server,
}
except TimeoutError:
if proc.returncode is None:
startup_kill_attempted = True
await _stop_process(proc, timeout=2)
returncode = proc.returncode
stderr_bytes = b""
if proc.stderr:
try:
stderr_bytes = await asyncio.wait_for(proc.stderr.read(8192), timeout=2)
except asyncio.TimeoutError:
pass
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
pytest.fail(
f"extension cleanup server failed to start on port {gateway_port} "
f"(returncode={returncode}).\nstderr:\n{stderr_text}"
)
finally:
if proc.returncode is None:
if startup_kill_attempted:
await _stop_process(proc, timeout=2)
else:
await _stop_process(proc, sig=signal.SIGINT, timeout=10)
if proc.returncode is None:
await _stop_process(proc, timeout=2)
finally:
for sock in reserved:
if sock.fileno() != -1:
sock.close()
db_tmpdir.cleanup()
home_tmpdir.cleanup()
tools_tmpdir.cleanup()
channels_tmpdir.cleanup()
@pytest.fixture(scope="session")
async def http_channel_server(ironclaw_server, server_ports):
"""HTTP webhook channel base URL."""
@@ -579,6 +473,7 @@ async def http_channel_server_without_secret(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-tester",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"CLI_ENABLED": "false",
@@ -1,266 +0,0 @@
"""Extension uninstall secret cleanup E2E tests.
Exercises real install/setup/auth/remove flows and verifies the backing
secrets table is cleaned up when extensions are uninstalled.
"""
import sqlite3
from urllib.parse import parse_qs, urlparse
import httpx
from helpers import api_get, api_post
def _extract_state(auth_url: str) -> str:
parsed = urlparse(auth_url)
state = parse_qs(parsed.query).get("state", [None])[0]
assert state, f"auth_url should include state: {auth_url}"
return state
def _secret_exists(db_path: str, user_id: str, name: str) -> bool:
with sqlite3.connect(db_path) as conn:
row = conn.execute(
"SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2 LIMIT 1",
(user_id, name),
).fetchone()
return row is not None
def _secret_names(db_path: str, user_id: str) -> set[str]:
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT name FROM secrets WHERE user_id = ?1",
(user_id,),
).fetchall()
return {row[0] for row in rows}
async def _get_extension(base_url: str, name: str) -> dict | None:
response = await api_get(base_url, "/api/extensions", timeout=15)
response.raise_for_status()
for extension in response.json().get("extensions", []):
if extension["name"] == name:
return extension
return None
async def _ensure_removed(base_url: str, name: str) -> None:
extension = await _get_extension(base_url, name)
if extension is not None:
response = await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
assert response.status_code == 200, response.text
assert response.json().get("success") is True, response.text
async def _install_extension(
base_url: str,
name: str,
*,
kind: str | None = None,
url: str | None = None,
) -> None:
payload = {"name": name}
if kind is not None:
payload["kind"] = kind
if url is not None:
payload["url"] = url
response = await api_post(
base_url,
"/api/extensions/install",
json=payload,
timeout=180,
)
assert response.status_code == 200, response.text
assert response.json().get("success") is True, response.text
async def test_remove_wasm_tool_deletes_unique_secret(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
await _ensure_removed(server, "web-search")
await _install_extension(server, "web-search")
setup_response = await api_post(
server,
"/api/extensions/web-search/setup",
json={"secrets": {"brave_api_key": "cleanup-test-key"}},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
assert setup_response.json().get("success") is True, setup_response.text
assert _secret_exists(db_path, user_id, "brave_api_key")
remove_response = await api_post(
server,
"/api/extensions/web-search/remove",
timeout=30,
)
assert remove_response.status_code == 200, remove_response.text
assert remove_response.json().get("success") is True, remove_response.text
assert not _secret_exists(db_path, user_id, "brave_api_key")
async def test_remove_wasm_channel_deletes_setup_secrets(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
await _ensure_removed(server, "discord")
await _install_extension(server, "discord", kind="wasm_channel")
setup_response = await api_post(
server,
"/api/extensions/discord/setup",
json={
"secrets": {
"discord_bot_token": "cleanup-discord-bot-token",
"discord_public_key": "cleanup-discord-public-key",
}
},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
assert setup_response.json().get("success") is True, setup_response.text
assert _secret_exists(db_path, user_id, "discord_bot_token")
assert _secret_exists(db_path, user_id, "discord_public_key")
remove_response = await api_post(
server,
"/api/extensions/discord/remove",
timeout=30,
)
assert remove_response.status_code == 200, remove_response.text
assert remove_response.json().get("success") is True, remove_response.text
assert not _secret_exists(db_path, user_id, "discord_bot_token")
assert not _secret_exists(db_path, user_id, "discord_public_key")
async def test_remove_shared_google_oauth_secrets_after_last_tool(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
await _ensure_removed(server, "gmail")
await _ensure_removed(server, "google-drive")
await _install_extension(server, "gmail")
await _install_extension(server, "google-drive")
setup_response = await api_post(
server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
auth_url = setup_response.json().get("auth_url")
assert auth_url, setup_response.text
async with httpx.AsyncClient() as client:
callback_response = await client.get(
f"{server}/oauth/callback",
params={"code": "mock_auth_code", "state": _extract_state(auth_url)},
timeout=30,
follow_redirects=True,
)
assert callback_response.status_code == 200, callback_response.text[:400]
shared_secrets = [
"google_oauth_token",
"google_oauth_token_refresh_token",
"google_oauth_token_scopes",
]
for secret_name in shared_secrets:
assert _secret_exists(db_path, user_id, secret_name), f"expected {secret_name} to exist"
gmail_remove_response = await api_post(
server,
"/api/extensions/gmail/remove",
timeout=30,
)
assert gmail_remove_response.status_code == 200, gmail_remove_response.text
assert gmail_remove_response.json().get("success") is True, gmail_remove_response.text
for secret_name in shared_secrets:
assert _secret_exists(db_path, user_id, secret_name), (
f"{secret_name} should remain while google-drive is still installed"
)
drive_remove_response = await api_post(
server,
"/api/extensions/google-drive/remove",
timeout=30,
)
assert drive_remove_response.status_code == 200, drive_remove_response.text
assert drive_remove_response.json().get("success") is True, drive_remove_response.text
for secret_name in shared_secrets:
assert not _secret_exists(db_path, user_id, secret_name), (
f"{secret_name} should be deleted after the last Google tool is removed"
)
async def test_remove_mcp_server_deletes_stored_secrets(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
mcp_url = f"{extension_cleanup_server['mock_llm_url']}/mcp"
await _ensure_removed(server, "mock-mcp")
await _install_extension(server, "mock-mcp", kind="mcp_server", url=mcp_url)
setup_response = await api_post(
server,
"/api/extensions/mock-mcp/setup",
json={"secrets": {}},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
auth_url = setup_response.json().get("auth_url")
if auth_url is None:
activate_response = await api_post(
server,
"/api/extensions/mock-mcp/activate",
timeout=30,
)
assert activate_response.status_code == 200, activate_response.text
auth_url = activate_response.json().get("auth_url")
assert auth_url, "mock-mcp should require OAuth in E2E"
async with httpx.AsyncClient() as client:
callback_response = await client.get(
f"{server}/oauth/callback",
params={"code": "mock_mcp_code", "state": _extract_state(auth_url)},
timeout=30,
follow_redirects=True,
)
assert callback_response.status_code == 200, callback_response.text[:400]
expected_mcp_secrets = [
"mcp_mock-mcp_access_token",
"mcp_mock-mcp_client_id",
]
stored_secret_names = _secret_names(db_path, user_id)
for secret_name in expected_mcp_secrets:
assert secret_name in stored_secret_names, (
f"expected {secret_name} to exist; stored secrets were {sorted(stored_secret_names)}"
)
remove_response = await api_post(
server,
"/api/extensions/mock-mcp/remove",
timeout=30,
)
assert remove_response.status_code == 200, remove_response.text
assert remove_response.json().get("success") is True, remove_response.text
remaining_secret_names = _secret_names(db_path, user_id)
assert not any(name.startswith("mcp_mock-mcp_") for name in remaining_secret_names), (
f"mock-mcp secrets should be deleted on remove; remaining secrets were "
f"{sorted(remaining_secret_names)}"
)
+10 -17
View File
@@ -42,6 +42,8 @@ const ALICE_USER_ID: &str = "alice";
const BOB_USER_ID: &str = "bob";
const OWNER_TOKEN: &str = "tok-owner-secret";
const OWNER_SCOPE_ID: &str = "owner-scope";
const GATEWAY_SENDER_ID: &str = "gateway-sender";
/// Build a MultiAuthState with two users.
fn two_user_auth() -> MultiAuthState {
let mut tokens = HashMap::new();
@@ -49,7 +51,6 @@ fn two_user_auth() -> MultiAuthState {
ALICE_TOKEN.to_string(),
UserIdentity {
user_id: ALICE_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -57,7 +58,6 @@ fn two_user_auth() -> MultiAuthState {
BOB_TOKEN.to_string(),
UserIdentity {
user_id: BOB_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -79,10 +79,7 @@ fn user_echo_app(auth: MultiAuthState) -> Router {
.route("/api/whoami/scopes", get(echo_user_with_scopes))
.route("/api/action", post(echo_user))
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
.layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
}
// ===========================================================================
@@ -547,6 +544,7 @@ fn gateway_state_has_multi_tenant_fields() {
prompt_queue: None,
scheduler: None,
owner_id: "fallback".to_string(),
default_sender_id: "fallback".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -560,11 +558,10 @@ fn gateway_state_has_multi_tenant_fields() {
startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(),
secrets_store: None,
db_auth: None,
};
assert_eq!(state.owner_id, "fallback");
assert_eq!(state.default_sender_id, "fallback");
assert!(state.workspace_pool.is_none());
}
@@ -595,7 +592,6 @@ async fn start_owner_scoped_sender_server() -> (
OWNER_TOKEN.to_string(),
UserIdentity {
user_id: OWNER_SCOPE_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -603,7 +599,6 @@ async fn start_owner_scoped_sender_server() -> (
BOB_TOKEN.to_string(),
UserIdentity {
user_id: BOB_USER_ID.to_string(),
role: "member".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -623,6 +618,7 @@ async fn start_owner_scoped_sender_server() -> (
prompt_queue: None,
scheduler: None,
owner_id: OWNER_SCOPE_ID.to_string(),
default_sender_id: GATEWAY_SENDER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -636,11 +632,9 @@ async fn start_owner_scoped_sender_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: Default::default(),
secrets_store: None,
db_auth: None,
});
let auth = MultiAuthState::multi(tokens).into();
let auth = MultiAuthState::multi(tokens);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = start_server(addr, state.clone(), auth)
.await
@@ -775,7 +769,7 @@ async fn full_server_chat_send_rewrites_sender_only_for_owner_scope_rebind() {
.expect("Timed out waiting for owner message")
.expect("Agent channel closed");
assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.sender_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.sender_id, GATEWAY_SENDER_ID);
assert_eq!(owner_msg.content, "hello from owner");
let other_resp = client
@@ -1009,6 +1003,7 @@ async fn start_multi_user_server_with_db() -> (
prompt_queue: None,
scheduler: None,
owner_id: ALICE_USER_ID.to_string(),
default_sender_id: ALICE_USER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -1022,12 +1017,10 @@ async fn start_multi_user_server_with_db() -> (
startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(),
secrets_store: None,
db_auth: None,
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into())
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth)
.await
.expect("Failed to start server with DB");
+5 -7
View File
@@ -205,6 +205,7 @@ async fn start_test_server_with_provider(
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(llm_provider),
@@ -218,8 +219,6 @@ async fn start_test_server_with_provider(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -227,7 +226,7 @@ async fn start_test_server_with_provider(
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth.into())
let bound_addr = start_server(addr, state.clone(), auth)
.await
.expect("Failed to start test server");
@@ -705,6 +704,7 @@ async fn test_no_llm_provider_returns_503() {
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, // No LLM!
@@ -718,8 +718,6 @@ async fn test_no_llm_provider_returns_503() {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -727,7 +725,7 @@ async fn test_no_llm_provider_returns_503() {
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state, auth.into()).await.unwrap();
let bound_addr = start_server(addr, state, auth).await.unwrap();
let url = format!("http://{}/v1/chat/completions", bound_addr);
let resp = client()
@@ -765,7 +763,7 @@ async fn test_chat_completions_body_too_large() {
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
)
.route_layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth_state),
auth_state,
ironclaw::channels::web::auth::auth_middleware,
))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
+2 -3
View File
@@ -227,6 +227,7 @@ impl GatewayWorkflowHarness {
prompt_queue: None,
scheduler: Some(scheduler_slot.clone()),
owner_id: user_id.clone(),
default_sender_id: user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(Arc::clone(&components.llm)),
@@ -240,8 +241,6 @@ impl GatewayWorkflowHarness {
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let mut agent = Agent::new(
@@ -300,7 +299,7 @@ impl GatewayWorkflowHarness {
let addr = start_server(
"127.0.0.1:0".parse().expect("valid localhost addr"),
Arc::clone(&gateway_state),
auth.into(),
auth,
)
.await
.expect("failed to start gateway server");
+2 -3
View File
@@ -52,6 +52,7 @@ async fn start_test_server() -> (
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -65,8 +66,6 @@ async fn start_test_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -74,7 +73,7 @@ async fn start_test_server() -> (
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth.into())
let bound_addr = start_server(addr, state.clone(), auth)
.await
.expect("Failed to start test server");