diff --git a/Cargo.lock b/Cargo.lock index c3747590..dfea8b45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,6 +3482,7 @@ dependencies = [ "wasmparser 0.220.1", "wasmtime", "wasmtime-wasi", + "webpki-roots 0.26.11", "zbus", "zip", ] @@ -6991,6 +6992,7 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", "tower 0.5.3", diff --git a/Cargo.toml b/Cargo.toml index 41895b16..2d1d5ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ 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"] } @@ -95,7 +96,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"] } +tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] } # Cron scheduling for routines cron = "0.13" @@ -219,6 +220,7 @@ postgres = [ "dep:tokio-postgres-rustls", "dep:rustls", "dep:rustls-native-certs", + "dep:webpki-roots", "dep:postgres-types", "dep:refinery", "dep:pgvector", diff --git a/Dockerfile b/Dockerfile index a2c2610d..14e245b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,71 @@ # 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: Build -FROM rust:1.92-slim-bookworm AS builder +# Stage 1: Install cargo-chef +FROM rust:1.92-slim-bookworm AS chef 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 wasm-tools + && cargo install cargo-chef wasm-tools WORKDIR /app -# Copy manifests first for layer caching +# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change) +FROM chef AS planner + 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 -# [[bench]] entries in Cargo.toml require bench sources to exist for cargo to parse the manifest + +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/ 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 2: Runtime +# Stage 5: 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 diff --git a/docs/USER_MANAGEMENT_API.md b/docs/USER_MANAGEMENT_API.md new file mode 100644 index 00000000..3767215d --- /dev/null +++ b/docs/USER_MANAGEMENT_API.md @@ -0,0 +1,572 @@ +# 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 `. 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": "alice@example.com", + "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": "alice@example.com", + "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": "alice@example.com", + "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": "alice@example.com", + "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": "alice@example.com", + "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` | | diff --git a/migrations/V14__users.sql b/migrations/V14__users.sql new file mode 100644 index 00000000..fe4a40fb --- /dev/null +++ b/migrations/V14__users.sql @@ -0,0 +1,31 @@ +-- 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); diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 4ee846f7..59f1f87d 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -345,13 +345,25 @@ 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)), - _ => self - .deps - .store - .as_ref() - .map(|db| Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)))), + _ => { + 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 + } + } }; crate::tenant::TenantCtx::new( @@ -1263,6 +1275,34 @@ 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 diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 35f76ed1..9e639171 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -400,16 +400,21 @@ impl<'a> LoopDelegate for ChatDelegate<'a> { }; // Record cost and track token usage (global + per-user). - // 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) + // 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 } else { - ( - self.agent.llm().active_model_name(), - Some(self.agent.llm().cost_per_token()), - ) + 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(); @@ -433,6 +438,24 @@ 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) } @@ -1360,6 +1383,7 @@ 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, @@ -2241,6 +2265,7 @@ 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, @@ -2368,6 +2393,7 @@ 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, diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index f7a8f869..18fafc1d 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -400,7 +400,7 @@ impl HeartbeatRunner { } /// Send a notification about heartbeat findings. - pub(crate) async fn send_notification(&self, message: &str) { + 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 that -/// own routines (enabled or not). Each tick, it queries the DB for distinct +/// 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 /// 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,8 +574,10 @@ pub fn spawn_multi_user_heartbeat( } }; - // Run user heartbeats concurrently so one slow LLM call doesn't - // block others. Cap concurrency to avoid flooding the LLM provider. + // 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. const MAX_CONCURRENT_HEARTBEATS: usize = 8; let mut join_set = tokio::task::JoinSet::new(); @@ -588,23 +590,6 @@ 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 { @@ -613,13 +598,30 @@ pub fn spawn_multi_user_heartbeat( } let uid = user_id.clone(); - let cfg = config.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 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); diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs index 88eb2a64..d6fd4bc9 100644 --- a/src/agent/scheduler.rs +++ b/src/agent/scheduler.rs @@ -267,6 +267,20 @@ 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| { @@ -784,6 +798,7 @@ 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, diff --git a/src/app.rs b/src/app.rs index 8fb950fb..94262c3a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -336,17 +336,12 @@ impl AppBuilder { ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone()); let ws = Arc::new(ws); - // Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured, + // Detect multi-tenant mode: when the database has registered users, // 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 = self - .config - .channels - .gateway - .as_ref() - .is_some_and(|gw| gw.user_tokens.is_some()); + let is_multi_tenant = db.has_any_users().await.unwrap_or(false); if is_multi_tenant { let pool = Arc::new(crate::channels::web::server::WorkspacePool::new( diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index 8db9a6b7..28fc5d55 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -91,6 +91,34 @@ 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 | |--------|------|-------------| diff --git a/src/channels/web/auth.rs b/src/channels/web/auth.rs index 7dc8adb4..acd24458 100644 --- a/src/channels/web/auth.rs +++ b/src/channels/web/auth.rs @@ -5,6 +5,7 @@ //! handlers can extract it via `AuthenticatedUser`. use std::collections::HashMap; +use std::num::NonZeroUsize; use axum::{ extract::{FromRequestParts, Request, State}, @@ -13,18 +14,25 @@ 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, } /// Hash a token with SHA-256 for constant-size, timing-safe storage. -fn hash_token(token: &str) -> [u8; 32] { +pub fn hash_token(token: &str) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hasher.finalize().into() @@ -56,6 +64,7 @@ impl MultiAuthState { hash, UserIdentity { user_id, + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, )], @@ -64,6 +73,11 @@ 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) -> Self { let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens .into_iter() @@ -108,6 +122,131 @@ 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, + /// Bounded LRU cache: token_hash → (identity, inserted_at). + cache: Arc>>, +} + +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) -> 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, ()> { + 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, +} + +impl From 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 @@ -130,6 +269,31 @@ 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 FromRequestParts for AdminUser +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let identity = parts + .extensions + .get::() + .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 @@ -166,39 +330,65 @@ fn query_token(request: &Request) -> Option { /// Auth middleware that validates bearer token from header or query param. /// -/// SSE connections can't set headers from `EventSource`, so we also accept -/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints. +/// 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. /// /// On successful authentication, inserts the matching `UserIdentity` into /// request extensions for downstream extraction via `AuthenticatedUser`. pub async fn auth_middleware( - State(auth): State, + State(auth): State, headers: HeaderMap, mut request: Request, next: Next, ) -> Response { - // Try Authorization header first. - // RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive. + // 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 { + // Try Authorization header first (RFC 6750). 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..]) { - request.extensions_mut().insert(identity.clone()); - return next.run(request).await; + return Some(value[7..].to_string()); } - // 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; + // Fall back to query parameter for SSE/WS endpoints. + if allows_query_token_auth(request) { + return query_token(request); } - (StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response() + None } #[cfg(test)] @@ -227,6 +417,7 @@ mod tests { "tok-alice".to_string(), UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, ); @@ -234,6 +425,7 @@ mod tests { "tok-bob".to_string(), UserIdentity { user_id: "bob".to_string(), + role: "admin".to_string(), workspace_read_scopes: Vec::new(), }, ); @@ -274,7 +466,10 @@ mod tests { /// Router with streaming endpoints (query auth allowed) and regular /// endpoints (query auth rejected). fn test_app(token: &str) -> Router { - let state = MultiAuthState::single(token.to_string(), "test-user".to_string()); + let state = CombinedAuthState::from(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)) @@ -486,7 +681,7 @@ mod tests { /// Build a multi-user router where each token maps to a distinct identity. fn multi_user_app(tokens: HashMap) -> Router { - let state = MultiAuthState::multi(tokens); + let state = CombinedAuthState::from(MultiAuthState::multi(tokens)); Router::new() .route("/api/chat/events", get(identity_handler)) .route("/api/chat/send", post(identity_handler)) @@ -500,6 +695,7 @@ mod tests { "tok-alice".to_string(), UserIdentity { user_id: "alice".to_string(), + role: "admin".to_string(), workspace_read_scopes: vec!["shared".to_string()], }, ); @@ -507,6 +703,7 @@ 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()], }, ); @@ -643,7 +840,10 @@ 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 = MultiAuthState::single("tok-only".to_string(), "solo".to_string()); + let state = CombinedAuthState::from(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)); diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs index 50c7a0b9..b8958527 100644 --- a/src/channels/web/handlers/mod.rs +++ b/src/channels/web/handlers/mod.rs @@ -5,7 +5,10 @@ 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. diff --git a/src/channels/web/handlers/secrets.rs b/src/channels/web/handlers/secrets.rs new file mode 100644 index 00000000..f8bfe5e4 --- /dev/null +++ b/src/channels/web/handlers/secrets.rs @@ -0,0 +1,183 @@ +//! 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>, + AdminUser(_admin): AdminUser, + Path((user_id, name)): Path<(String, String)>, + Json(body): Json, +) -> Result, (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>, + AdminUser(_admin): AdminUser, + Path(user_id): Path, +) -> Result, (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 = 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>, + AdminUser(_admin): AdminUser, + Path((user_id, name)): Path<(String, String)>, +) -> Result, (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, + }))) +} diff --git a/src/channels/web/handlers/tokens.rs b/src/channels/web/handlers/tokens.rs new file mode 100644 index 00000000..9cbcc236 --- /dev/null +++ b/src/channels/web/handlers/tokens.rs @@ -0,0 +1,163 @@ +//! 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>, + AuthenticatedUser(user): AuthenticatedUser, + Json(body): Json, +) -> Result, (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 = 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>, + AuthenticatedUser(user): AuthenticatedUser, +) -> Result, (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 = 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>, + AuthenticatedUser(user): AuthenticatedUser, + Path(id): Path, +) -> Result, (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(), + }))) +} diff --git a/src/channels/web/handlers/users.rs b/src/channels/web/handlers/users.rs new file mode 100644 index 00000000..39b26091 --- /dev/null +++ b/src/channels/web/handlers/users.rs @@ -0,0 +1,534 @@ +//! 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 { + 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>, + AdminUser(user): AdminUser, + Json(body): Json, +) -> Result, (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>, + AdminUser(_user): AdminUser, +) -> Result, (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 = summary_stats + .into_iter() + .map(|s| (s.user_id.clone(), s)) + .collect(); + + let mut users_json: Vec = 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>, + AdminUser(_user): AdminUser, + Path(id): Path, +) -> Result, (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>, + AdminUser(_user): AdminUser, + Path(id): Path, + Json(body): Json, +) -> Result, (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>, + AdminUser(_user): AdminUser, + Path(id): Path, +) -> Result, (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>, + AdminUser(_user): AdminUser, + Path(id): Path, +) -> Result, (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>, + AdminUser(_user): AdminUser, + Path(id): Path, +) -> Result, (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>, + AuthenticatedUser(user): AuthenticatedUser, +) -> Result, (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>, + AuthenticatedUser(user): AuthenticatedUser, + Json(body): Json, +) -> Result, (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(¤t.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 { + ¤t.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>, + AdminUser(_user): AdminUser, + axum::extract::Query(params): axum::extract::Query>, +) -> Result, (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 = 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, + }))) +} diff --git a/src/channels/web/handlers/webhooks.rs b/src/channels/web/handlers/webhooks.rs index 1fd78c66..b8d4fd8d 100644 --- a/src/channels/web/handlers/webhooks.rs +++ b/src/channels/web/handlers/webhooks.rs @@ -56,13 +56,24 @@ 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. For multi-tenant isolation, use the user-scoped endpoint at +/// users. Disabled in multi-tenant mode — use the user-scoped endpoint at /// `/api/webhooks/u/{user_id}/{path}` instead. pub async fn webhook_trigger_handler( State(state): State>, Path(path): Path, headers: HeaderMap, ) -> Result, (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 } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 8ae9051f..2cadece6 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -56,7 +56,7 @@ use crate::workspace::Workspace; use self::log_layer::{LogBroadcaster, LogLevelHandle}; -use self::auth::MultiAuthState; +use self::auth::{CombinedAuthState, DbAuthenticator, 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, - /// Multi-user auth state (replaces bare auth_token). - auth: MultiAuthState, + /// Combined auth state: env-var tokens + optional DB-backed tokens. + auth: CombinedAuthState, } 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) -> Self { + pub fn new(config: GatewayConfig, owner_id: String) -> Self { let auth_token = config.auth_token.clone().unwrap_or_else(|| { use rand::RngCore; use rand::rngs::OsRng; @@ -83,64 +83,11 @@ impl GatewayChannel { bytes.iter().map(|b| format!("{b:02x}")).collect() }); - let auth = MultiAuthState::single(auth_token, config.user_id.clone()); - - 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), - webhook_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(), - active_config: server::ActiveConfigSnapshot::default(), - }); - - 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) -> 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 + let auth = CombinedAuthState { + env_auth: MultiAuthState::single(auth_token, owner_id.clone()), + db_auth: 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()), @@ -155,8 +102,7 @@ impl GatewayChannel { job_manager: None, prompt_queue: None, scheduler: None, - owner_id: config.user_id.clone(), - default_sender_id: config.user_id.clone(), + owner_id, shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), llm_provider: None, @@ -164,12 +110,14 @@ impl GatewayChannel { skill_catalog: None, chat_rate_limiter: server::PerUserRateLimiter::new(30, 60), oauth_rate_limiter: server::RateLimiter::new(10, 60), + webhook_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(), + secrets_store: None, + db_auth: None, }); Self { @@ -197,7 +145,6 @@ 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(), @@ -211,6 +158,8 @@ 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); @@ -258,6 +207,17 @@ impl GatewayChannel { self } + /// Enable DB-backed token authentication alongside env-var tokens. + pub fn with_db_auth(mut self, store: Arc) -> 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) -> Self { self.rebuild_state(|s| s.job_manager = Some(jm)); @@ -328,6 +288,15 @@ impl GatewayChannel { self } + /// Inject the secrets store for admin secret provisioning. + pub fn with_secrets_store( + mut self, + store: Arc, + ) -> 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) -> Self { self.rebuild_state(|s| s.workspace_pool = Some(pool)); @@ -336,7 +305,7 @@ impl GatewayChannel { /// Get the first auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { - self.auth.first_token().unwrap_or("") + self.auth.env_auth.first_token().unwrap_or("") } /// Get a reference to the shared gateway state (for the agent to push SSE events). diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 73e183d2..8c9ecdde 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -16,7 +16,7 @@ use axum::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, - routing::{get, post}, + routing::{get, post, put}, }; 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, MultiAuthState, UserIdentity, auth_middleware, + AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware, }; use crate::channels::web::handlers::jobs::{ job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, @@ -290,7 +290,22 @@ 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 } } @@ -347,8 +362,6 @@ pub struct GatewayState { pub prompt_queue: Option, /// 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>>, /// WebSocket connection tracker. @@ -378,6 +391,10 @@ 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>, + /// DB auth cache for invalidation on security-critical actions. + pub db_auth: Option>, } /// Start the gateway HTTP server. @@ -386,7 +403,7 @@ pub struct GatewayState { pub async fn start_server( addr: SocketAddr, state: Arc, - auth: MultiAuthState, + auth: CombinedAuthState, ) -> Result { let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { crate::error::ChannelError::StartupFailed { @@ -512,6 +529,57 @@ 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 @@ -571,6 +639,7 @@ 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([ @@ -585,6 +654,33 @@ 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| { + let detail = if let Some(s) = panic_info.downcast_ref::() { + 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, @@ -1332,9 +1428,6 @@ 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 @@ -1436,9 +1529,6 @@ 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); @@ -3041,7 +3131,6 @@ mod tests { 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, @@ -3056,6 +3145,8 @@ 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, }) } @@ -3276,6 +3367,7 @@ 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(), }); @@ -3360,6 +3452,7 @@ 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(), }); @@ -3409,7 +3502,10 @@ mod tests { let state = test_gateway_state(None); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let auth = MultiAuthState::single("test-token".to_string(), "test".to_string()); + let auth = CombinedAuthState::from(crate::channels::web::auth::MultiAuthState::single( + "test-token".to_string(), + "test".to_string(), + )); let bound = start_server(addr, state.clone(), auth) .await .expect("server should start"); diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 6b366482..9cfd35df 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -186,6 +186,13 @@ 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(); @@ -4339,6 +4346,165 @@ 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' ? '' + I18n.t('users.roleAdmin') + '' : '' + I18n.t('users.roleMember') + ''; + var actions = ''; + if (u.status === 'active') { + actions += ' '; + } else { + actions += ' '; + } + if (u.role === 'member') { + actions += ' '; + } else { + actions += ' '; + } + actions += ''; + return '' + + '' + escapeHtml(u.id.substring(0, 8)) + '…' + + '' + escapeHtml(u.display_name) + '' + + '' + escapeHtml(u.email || '—') + '' + + '' + roleLabel + '' + + '' + escapeHtml(u.status) + '' + + '' + (u.job_count || 0) + '' + + '' + formatCost(u.total_cost) + '' + + '' + (u.last_active_at ? formatRelativeTime(u.last_active_at) : '—') + '' + + '' + formatRelativeTime(u.created_at) + '' + + '' + actions + '' + + ''; + }).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 = '' + escapeHtml(heading) + ' ' + I18n.t('users.tokenShareMessage') + '
' + + '' + escapeHtml(loginUrl) + '' + + '' + + '
' + I18n.t('users.rawToken') + ' ' + escapeHtml(tokenValue) + ''; + 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; @@ -5028,6 +5194,7 @@ 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(); } diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 761767fe..4592427f 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -9,8 +9,6 @@ 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...', @@ -44,7 +42,46 @@ 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', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 0fb1568a..a0d33438 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -9,8 +9,6 @@ I18n.register('zh-CN', { 'auth.connect': '连接', 'auth.errorRequired': '请输入令牌', 'auth.errorInvalid': '令牌无效', - 'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN', - // 聊天 'chat.inputPlaceholder': '输入消息或 / 以使用命令...', @@ -44,7 +42,46 @@ 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': '已断开', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 7aa2c86f..21ff6faa 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -41,7 +41,6 @@
-

Enter the GATEWAY_AUTH_TOKEN from your .env configuration.

@@ -293,6 +292,7 @@ +
@@ -390,6 +390,29 @@
+
+
+
+

User Management

+ +
+ + + + + + + +
IDDisplay NameEmailRoleStatusJobsCostLast ActiveCreatedActions
+ +
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 87afea87..6e0dfcf6 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -5429,3 +5429,22 @@ 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; } diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs index 0f7e5d12..0061f852 100644 --- a/src/channels/web/test_helpers.rs +++ b/src/channels/web/test_helpers.rs @@ -77,7 +77,6 @@ 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, @@ -92,6 +91,8 @@ 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, }) } @@ -106,7 +107,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).await?; + let bound = start_server(addr, state.clone(), auth.into()).await?; Ok((bound, state)) } @@ -120,7 +121,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).await?; + let bound = start_server(addr, state.clone(), auth.into()).await?; Ok((bound, state)) } } diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index 335f841c..4428b411 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -16,7 +16,6 @@ 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, }; @@ -24,7 +23,6 @@ use crate::channels::web::server::{ ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool, }; use crate::channels::web::sse::SseManager; -use crate::config::GatewayConfig; // ── Helpers ──────────────────────────────────────────────────────────── @@ -35,6 +33,7 @@ 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()], }, ); @@ -42,6 +41,7 @@ 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,7 +67,6 @@ 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, @@ -82,43 +81,11 @@ 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 @@ -225,6 +192,7 @@ 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; @@ -253,6 +221,7 @@ 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; @@ -276,6 +245,7 @@ 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; @@ -302,10 +272,12 @@ 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![], }; @@ -337,6 +309,7 @@ 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; @@ -377,7 +350,10 @@ 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(auth, auth_middleware)) + .layer(middleware::from_fn_with_state( + crate::channels::web::auth::CombinedAuthState::from(auth), + auth_middleware, + )) .with_state(state) } @@ -583,7 +559,10 @@ 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(auth, auth_middleware)) + .layer(middleware::from_fn_with_state( + crate::channels::web::auth::CombinedAuthState::from(auth), + auth_middleware, + )) .with_state(state) } @@ -708,7 +687,10 @@ 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(auth, auth_middleware)) + .layer(middleware::from_fn_with_state( + crate::channels::web::auth::CombinedAuthState::from(auth), + auth_middleware, + )) .with_state(state) } @@ -831,3 +813,140 @@ 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 + } + } +} diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 51beaafd..d866ee90 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -521,7 +521,6 @@ 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, @@ -535,6 +534,8 @@ 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, } } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 611d7247..5da12e0b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -352,7 +352,7 @@ pub async fn run_routines_cli( .await .map_err(|e| anyhow::anyhow!("{e:#}"))?; - let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string()); + let user_id = std::env::var("IRONCLAW_OWNER_ID").unwrap_or_else(|_| "default".to_string()); run_routines_command(routines_cmd.clone(), db, &user_id).await } diff --git a/src/config/agent.rs b/src/config/agent.rs index cfa0879a..a74eb4d6 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env}; +use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env}; use crate::error::ConfigError; use crate::settings::Settings; @@ -31,10 +31,12 @@ 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, /// Maximum tokens per job (0 = unlimited). pub max_tokens_per_job: u64, /// Whether the deployment is multi-tenant (multiple users sharing one - /// instance). Auto-detected from GATEWAY_USER_TOKENS presence. + /// instance). Defaults to false; can be set via AGENT_MULTI_TENANT env var. pub multi_tenant: bool, /// Maximum concurrent LLM calls per user. None = use default (4). pub max_llm_concurrent_per_user: Option, @@ -62,6 +64,7 @@ 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, @@ -122,13 +125,12 @@ 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, )?, - // 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(), + multi_tenant: parse_bool_env("AGENT_MULTI_TENANT", false)?, max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?, max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?, }) diff --git a/src/config/channels.rs b/src/config/channels.rs index d9c2c0a9..dec04f39 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -1,13 +1,11 @@ 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)] @@ -45,27 +43,15 @@ pub struct GatewayConfig { pub port: u16, /// Bearer token for authentication. Random hex generated at startup if unset. pub auth_token: Option, - 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 `user_id`. + /// these additional user scopes while writes remain isolated to the + /// authenticated user's own scope. /// Parsed from `WORKSPACE_READ_SCOPES` (comma-separated). pub workspace_read_scopes: Vec, /// Memory layer definitions (JSON in env var, or from external config). pub memory_layers: Vec, - /// 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>, -} - -/// 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, } /// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC). @@ -132,10 +118,6 @@ 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 = match optional_env("MEMORY_LAYERS")? { Some(json_str) => { @@ -144,7 +126,7 @@ impl ChannelsConfig { message: format!("must be valid JSON array of layer objects: {e}"), })? } - None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id), + None => crate::workspace::layer::MemoryLayer::default_for_user(owner_id), }; // Validate layer names and scopes @@ -196,41 +178,6 @@ impl ChannelsConfig { } } - let user_tokens: Option> = - match optional_env("GATEWAY_USER_TOKENS")? { - Some(json_str) => { - let tokens: HashMap = 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 = optional_env("WORKSPACE_READ_SCOPES")? .map(|s| { s.split(',') @@ -258,10 +205,8 @@ 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 @@ -416,15 +361,12 @@ 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] @@ -433,10 +375,8 @@ 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()); } @@ -563,7 +503,6 @@ 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"); diff --git a/src/config/heartbeat.rs b/src/config/heartbeat.rs index 09b8f0cd..ecf25333 100644 --- a/src/config/heartbeat.rs +++ b/src/config/heartbeat.rs @@ -21,8 +21,8 @@ pub struct HeartbeatConfig { pub quiet_hours_end: Option, /// Timezone for fire_at and quiet hours evaluation (IANA name). pub timezone: Option, - /// When true, cycle through all users with routines. Auto-detected from - /// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT. + /// When true, cycle through all users with routines. Controlled via + /// HEARTBEAT_MULTI_TENANT env var; defaults to false. pub multi_tenant: bool, } @@ -105,12 +105,7 @@ impl HeartbeatConfig { } tz }, - // 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(), - )?, + multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?, }) } } diff --git a/src/context/manager.rs b/src/context/manager.rs index 28343003..94ca48d2 100644 --- a/src/context/manager.rs +++ b/src/context/manager.rs @@ -195,6 +195,20 @@ 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 { self.contexts diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index 890aea0c..f3a323f3 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -12,6 +12,7 @@ mod routines; mod sandbox; mod settings; mod tool_failures; +mod users; mod workspace; use std::path::Path; diff --git a/src/db/libsql/users.rs b/src/db/libsql/users.rs new file mode 100644 index 00000000..f9ddfda9 --- /dev/null +++ b/src/db/libsql/users.rs @@ -0,0 +1,1024 @@ +//! UserStore implementation for LibSqlBackend. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use libsql::params; +use uuid::Uuid; + +use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text}; +use crate::db::libsql::LibSqlBackend; +use crate::db::{ApiTokenRecord, DatabaseError, UserRecord, UserStore}; + +fn row_to_user(row: &libsql::Row) -> Result { + let metadata_str = get_text(row, 9); + let metadata: serde_json::Value = serde_json::from_str(&metadata_str) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + Ok(UserRecord { + id: get_text(row, 0), + email: get_opt_text(row, 1), + display_name: get_text(row, 2), + status: get_text(row, 3), + role: get_text(row, 4), + created_at: get_ts(row, 5), + updated_at: get_ts(row, 6), + last_login_at: get_opt_ts(row, 7), + created_by: get_opt_text(row, 8), + metadata, + }) +} + +fn row_to_api_token(row: &libsql::Row) -> Result { + let id_str = get_text(row, 0); + let id: Uuid = id_str + .parse() + .map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?; + Ok(ApiTokenRecord { + id, + user_id: get_text(row, 1), + name: get_text(row, 2), + token_prefix: get_text(row, 3), + expires_at: get_opt_ts(row, 4), + last_used_at: get_opt_ts(row, 5), + created_at: get_ts(row, 6), + revoked_at: get_opt_ts(row, 7), + }) +} + +#[async_trait] +impl UserStore for LibSqlBackend { + async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> { + let conn = self.connect().await?; + let metadata_json = serde_json::to_string(&user.metadata) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + + 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) + "#, + params![ + user.id.as_str(), + opt_text(user.email.as_deref()), + user.display_name.as_str(), + user.status.as_str(), + user.role.as_str(), + fmt_ts(&user.created_at), + fmt_ts(&user.updated_at), + fmt_opt_ts(&user.last_login_at), + opt_text(user.created_by.as_deref()), + metadata_json, + ], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } + + async fn get_user(&self, id: &str) -> Result, DatabaseError> { + let conn = self.connect().await?; + let mut rows = conn + .query( + r#" + SELECT id, email, display_name, status, role, created_at, updated_at, + last_login_at, created_by, metadata + FROM users WHERE id = ?1 + "#, + params![id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + match rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + Some(row) => Ok(Some(row_to_user(&row)?)), + None => Ok(None), + } + } + + async fn get_user_by_email(&self, email: &str) -> Result, DatabaseError> { + let conn = self.connect().await?; + let mut rows = conn + .query( + r#" + SELECT id, email, display_name, status, role, created_at, updated_at, + last_login_at, created_by, metadata + FROM users WHERE email = ?1 + "#, + params![email], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + match rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + Some(row) => Ok(Some(row_to_user(&row)?)), + None => Ok(None), + } + } + + async fn list_users(&self, status: Option<&str>) -> Result, DatabaseError> { + let conn = self.connect().await?; + let mut users = Vec::new(); + + let mut rows = if let Some(status) = status { + conn.query( + r#" + 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 + "#, + params![status], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + } else { + conn.query( + r#" + 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 + .map_err(|e| DatabaseError::Query(e.to_string()))? + }; + + while let Some(row) = rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + users.push(row_to_user(&row)?); + } + Ok(users) + } + + async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + conn.execute( + "UPDATE users SET status = ?2, updated_at = ?3 WHERE id = ?1", + params![id, status, now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } + + async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + conn.execute( + "UPDATE users SET role = ?2, updated_at = ?3 WHERE id = ?1", + params![id, role, now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } + + async fn update_user_profile( + &self, + id: &str, + display_name: &str, + metadata: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + let metadata_json = serde_json::to_string(metadata) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + conn.execute( + "UPDATE users SET display_name = ?2, metadata = ?3, updated_at = ?4 WHERE id = ?1", + params![id, display_name, metadata_json, now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } + + async fn record_login(&self, id: &str) -> Result<(), DatabaseError> { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + conn.execute( + "UPDATE users SET last_login_at = ?2, updated_at = ?2 WHERE id = ?1", + params![id, now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } + + async fn create_api_token( + &self, + user_id: &str, + name: &str, + token_hash: &[u8; 32], + token_prefix: &str, + expires_at: Option>, + ) -> Result { + let conn = self.connect().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) + "#, + params![ + id.to_string(), + user_id, + libsql::Value::Blob(token_hash.to_vec()), + token_prefix, + name, + fmt_opt_ts(&expires_at), + fmt_ts(&now), + ], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + 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, + }) + } + + async fn list_api_tokens(&self, user_id: &str) -> Result, DatabaseError> { + let conn = self.connect().await?; + let mut 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 + "#, + params![user_id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + let mut tokens = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + tokens.push(row_to_api_token(&row)?); + } + Ok(tokens) + } + + async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + let rows_affected = conn + .execute( + r#" + UPDATE api_tokens SET revoked_at = ?3 + WHERE id = ?1 AND user_id = ?2 AND revoked_at IS NULL + "#, + params![token_id.to_string(), user_id, now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(rows_affected > 0) + } + + async fn authenticate_token( + &self, + token_hash: &[u8; 32], + ) -> Result, DatabaseError> { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + + let mut rows = conn + .query( + 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, u.email, u.display_name, u.status, u.role, u.created_at, + u.updated_at, u.last_login_at, u.created_by, u.metadata + FROM api_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ?1 + AND t.revoked_at IS NULL + AND (t.expires_at IS NULL OR t.expires_at > ?2) + AND u.status = 'active' + "#, + params![libsql::Value::Blob(token_hash.to_vec()), now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + match rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + Some(row) => { + let id_str = get_text(&row, 0); + let token_id: Uuid = id_str + .parse() + .map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?; + let token = ApiTokenRecord { + id: token_id, + user_id: get_text(&row, 1), + name: get_text(&row, 2), + token_prefix: get_text(&row, 3), + expires_at: get_opt_ts(&row, 4), + last_used_at: get_opt_ts(&row, 5), + created_at: get_ts(&row, 6), + revoked_at: get_opt_ts(&row, 7), + }; + + let metadata_str = get_text(&row, 17); + let metadata: serde_json::Value = serde_json::from_str(&metadata_str) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + + let user = UserRecord { + id: get_text(&row, 8), + email: get_opt_text(&row, 9), + display_name: get_text(&row, 10), + status: get_text(&row, 11), + role: get_text(&row, 12), + created_at: get_ts(&row, 13), + updated_at: get_ts(&row, 14), + last_login_at: get_opt_ts(&row, 15), + created_by: get_opt_text(&row, 16), + metadata, + }; + + Ok(Some((token, user))) + } + None => Ok(None), + } + } + + async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> { + let conn = self.connect().await?; + let now = fmt_ts(&Utc::now()); + conn.execute( + "UPDATE api_tokens SET last_used_at = ?2 WHERE id = ?1", + params![token_id.to_string(), now], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(()) + } + + async fn has_any_users(&self) -> Result { + let conn = self.connect().await?; + let mut rows = conn + .query("SELECT 1 FROM users LIMIT 1", ()) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + let has_users = rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + .is_some(); + Ok(has_users) + } + + async fn delete_user(&self, id: &str) -> Result { + let conn = self.connect().await?; + + conn.execute("BEGIN", ()) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + let result = async { + // Delete from child tables first to avoid FK violations. + // 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 + for table in &[ + "settings", + "heartbeat_state", + "tool_rate_limit_state", + "secret_usage_log", + "leak_detection_events", + "secrets", + "wasm_tools", + "routines", + "memory_documents", + "conversations", + "api_tokens", + ] { + conn.execute( + &format!("DELETE FROM {} WHERE user_id = ?1", table), + params![id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + } + // job_events references agent_jobs(id) without CASCADE — delete via subquery. + conn.execute( + "DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = ?1)", + params![id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + conn.execute("DELETE FROM agent_jobs WHERE user_id = ?1", params![id]) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + // Nullify self-referencing created_by before deleting the user + conn.execute( + "UPDATE users SET created_by = NULL WHERE created_by = ?1", + params![id], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + let rows = conn + .execute("DELETE FROM users WHERE id = ?1", params![id]) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok::<_, DatabaseError>(rows > 0) + } + .await; + + match result { + Ok(deleted) => { + conn.execute("COMMIT", ()) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + Ok(deleted) + } + Err(e) => { + let _ = conn.execute("ROLLBACK", ()).await; + Err(e) + } + } + } + + async fn user_usage_stats( + &self, + user_id: Option<&str>, + since: DateTime, + ) -> Result, DatabaseError> { + let conn = self.connect().await?; + let since_str = fmt_ts(&since); + let mut 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, + CAST(COALESCE(SUM(l.cost), 0) AS TEXT) 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 + "#, + params![since_str, uid], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + } 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, + CAST(COALESCE(SUM(l.cost), 0) AS TEXT) 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 + "#, + params![since_str], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + }; + let mut stats = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + let cost_str = get_text(&row, 5); + let total_cost = rust_decimal::Decimal::from_str_exact(&cost_str).map_err(|e| { + DatabaseError::Query(format!("invalid cost value '{}': {}", cost_str, e)) + })?; + stats.push(crate::db::UserUsageStats { + user_id: get_text(&row, 0), + model: get_text(&row, 1), + call_count: row + .get::(2) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + input_tokens: row + .get::(3) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + output_tokens: row + .get::(4) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + total_cost, + }); + } + Ok(stats) + } + + async fn create_user_with_token( + &self, + user: &UserRecord, + token_name: &str, + token_hash: &[u8; 32], + token_prefix: &str, + expires_at: Option>, + ) -> Result { + let conn = self.connect().await?; + let metadata_json = serde_json::to_string(&user.metadata) + .map_err(|e| DatabaseError::Serialization(e.to_string()))?; + + conn.execute("BEGIN", ()) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + // Insert user + if let Err(e) = 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) + "#, + params![ + user.id.as_str(), + opt_text(user.email.as_deref()), + user.display_name.as_str(), + user.status.as_str(), + user.role.as_str(), + fmt_ts(&user.created_at), + fmt_ts(&user.updated_at), + fmt_opt_ts(&user.last_login_at), + opt_text(user.created_by.as_deref()), + metadata_json, + ], + ) + .await + { + let _ = conn.execute("ROLLBACK", ()).await; + return Err(DatabaseError::Query(e.to_string())); + } + + // Insert token + let id = Uuid::new_v4(); + let now = Utc::now(); + if let Err(e) = 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) + "#, + params![ + id.to_string(), + user.id.as_str(), + libsql::Value::Blob(token_hash.to_vec()), + token_prefix, + token_name, + fmt_opt_ts(&expires_at), + fmt_ts(&now), + ], + ) + .await + { + let _ = conn.execute("ROLLBACK", ()).await; + return Err(DatabaseError::Query(e.to_string())); + } + + conn.execute("COMMIT", ()) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + 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, + }) + } + + async fn user_summary_stats( + &self, + user_id: Option<&str>, + ) -> Result, DatabaseError> { + let conn = self.connect().await?; + // Aggregate from llm_calls, resolving user_id via either agent_jobs + // (for background job calls) or conversations (for chat calls where + // job_id is NULL). Also count distinct agent_jobs per user. + let mut 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, + CAST(COALESCE(SUM(l.cost), 0) AS TEXT) 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) + "#, + params![uid], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + } else { + conn.query( + r#" + SELECT + COALESCE(j.user_id, c.user_id) AS user_id, + COUNT(DISTINCT j.id) AS job_count, + CAST(COALESCE(SUM(l.cost), 0) AS TEXT) 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 + .map_err(|e| DatabaseError::Query(e.to_string()))? + }; + let mut stats = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + { + let cost_str = get_text(&row, 2); + let total_cost = rust_decimal::Decimal::from_str_exact(&cost_str).map_err(|e| { + DatabaseError::Query(format!("invalid cost value '{}': {}", cost_str, e)) + })?; + stats.push(crate::db::UserSummaryStats { + user_id: get_text(&row, 0), + job_count: row + .get::(1) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + total_cost, + last_active_at: get_opt_ts(&row, 3), + }); + } + Ok(stats) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::libsql::LibSqlBackend; + use crate::db::{Database, UserStore}; + use sha2::{Digest, Sha256}; + + fn hash(s: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(s.as_bytes()); + h.finalize().into() + } + + async fn setup() -> (LibSqlBackend, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test_users.db"); + let db = LibSqlBackend::new_local(&db_path).await.unwrap(); + db.run_migrations().await.unwrap(); + (db, dir) // keep dir alive so the DB file isn't deleted + } + + fn test_user(id: &str) -> UserRecord { + UserRecord { + id: id.to_string(), + email: Some(format!("{}@test.com", id)), + display_name: id.to_string(), + status: "active".to_string(), + role: "member".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + } + } + + #[tokio::test] + async fn test_has_any_users_empty() { + let (db, _dir) = setup().await; + assert!(!db.has_any_users().await.unwrap()); + } + + #[tokio::test] + async fn test_create_and_get_user() { + let (db, _dir) = setup().await; + let user = test_user("alice"); + db.create_user(&user).await.unwrap(); + + assert!(db.has_any_users().await.unwrap()); + + let found = db.get_user("alice").await.unwrap().unwrap(); + assert_eq!(found.id, "alice"); + assert_eq!(found.email, Some("alice@test.com".to_string())); + assert_eq!(found.status, "active"); + } + + #[tokio::test] + async fn test_get_user_by_email() { + let (db, _dir) = setup().await; + db.create_user(&test_user("bob")).await.unwrap(); + + let found = db.get_user_by_email("bob@test.com").await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, "bob"); + + assert!( + db.get_user_by_email("nobody@test.com") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn test_list_users_with_status_filter() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + db.create_user(&test_user("bob")).await.unwrap(); + db.update_user_status("bob", "suspended").await.unwrap(); + + let all = db.list_users(None).await.unwrap(); + assert_eq!(all.len(), 2); + + let active = db.list_users(Some("active")).await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, "alice"); + + let suspended = db.list_users(Some("suspended")).await.unwrap(); + assert_eq!(suspended.len(), 1); + assert_eq!(suspended[0].id, "bob"); + } + + #[tokio::test] + async fn test_update_user_profile() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + + let meta = serde_json::json!({"role": "admin"}); + db.update_user_profile("alice", "Alice Smith", &meta) + .await + .unwrap(); + + let user = db.get_user("alice").await.unwrap().unwrap(); + assert_eq!(user.display_name, "Alice Smith"); + assert_eq!(user.metadata["role"], "admin"); + } + + #[tokio::test] + async fn test_token_lifecycle_create_authenticate_revoke() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + + // Create token + let token_hash = hash("secret-token-123"); + let record = db + .create_api_token("alice", "laptop", &token_hash, "secret-t", None) + .await + .unwrap(); + assert_eq!(record.user_id, "alice"); + assert_eq!(record.name, "laptop"); + assert_eq!(record.token_prefix, "secret-t"); + + // Authenticate + let (tok, user) = db.authenticate_token(&token_hash).await.unwrap().unwrap(); + assert_eq!(tok.id, record.id); + assert_eq!(user.id, "alice"); + + // List tokens + let tokens = db.list_api_tokens("alice").await.unwrap(); + assert_eq!(tokens.len(), 1); + + // Revoke + assert!(db.revoke_api_token(record.id, "alice").await.unwrap()); + + // Auth should fail after revoke + assert!(db.authenticate_token(&token_hash).await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_token_auth_fails_for_suspended_user() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + + let token_hash = hash("token-abc"); + db.create_api_token("alice", "test", &token_hash, "token-ab", None) + .await + .unwrap(); + + // Auth works while active + assert!(db.authenticate_token(&token_hash).await.unwrap().is_some()); + + // Suspend user + db.update_user_status("alice", "suspended").await.unwrap(); + + // Auth should fail + assert!(db.authenticate_token(&token_hash).await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_token_revoke_wrong_user_returns_false() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + db.create_user(&test_user("bob")).await.unwrap(); + + let token_hash = hash("alice-token"); + let record = db + .create_api_token("alice", "test", &token_hash, "alice-to", None) + .await + .unwrap(); + + // Bob can't revoke Alice's token + assert!(!db.revoke_api_token(record.id, "bob").await.unwrap()); + + // Alice can + assert!(db.revoke_api_token(record.id, "alice").await.unwrap()); + } + + #[tokio::test] + async fn test_record_login_and_token_usage() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + + let token_hash = hash("tok"); + let record = db + .create_api_token("alice", "test", &token_hash, "tok", None) + .await + .unwrap(); + + // Record usage + db.record_token_usage(record.id).await.unwrap(); + db.record_login("alice").await.unwrap(); + + // Verify timestamps updated + let user = db.get_user("alice").await.unwrap().unwrap(); + assert!(user.last_login_at.is_some()); + + let tokens = db.list_api_tokens("alice").await.unwrap(); + assert!(tokens[0].last_used_at.is_some()); + } + + #[tokio::test] + async fn test_delete_user_removes_api_tokens() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + + let token_hash = hash("alice-tok"); + db.create_api_token("alice", "primary", &token_hash, "alice-to", None) + .await + .unwrap(); + + // Verify token exists before deletion. + let tokens = db.list_api_tokens("alice").await.unwrap(); + assert_eq!(tokens.len(), 1); + + // Delete user — should also remove their api_tokens. + assert!(db.delete_user("alice").await.unwrap()); + + // api_tokens must be gone (not orphaned). + let tokens = db.list_api_tokens("alice").await.unwrap(); + assert!( + tokens.is_empty(), + "expected api_tokens to be deleted with user, found {}", + tokens.len() + ); + } + + /// Helper: insert a minimal agent_job row for testing usage stats. + async fn insert_test_job(db: &LibSqlBackend, job_id: &str, user_id: &str) { + let conn = db.connect().await.unwrap(); + conn.execute( + "INSERT INTO agent_jobs (id, title, description, status, source, user_id, created_at) \ + VALUES (?1, 'test', 'test job', 'completed', 'test', ?2, ?3)", + params![job_id, user_id, fmt_ts(&Utc::now())], + ) + .await + .unwrap(); + } + + /// Helper: insert a minimal llm_call row for testing usage stats. + async fn insert_test_llm_call(db: &LibSqlBackend, job_id: &str, model: &str, cost: &str) { + let conn = db.connect().await.unwrap(); + let call_id = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO llm_calls (id, job_id, provider, model, input_tokens, output_tokens, cost, created_at) \ + VALUES (?1, ?2, 'test', ?3, 100, 50, ?4, ?5)", + params![call_id, job_id, model, cost, fmt_ts(&Utc::now())], + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_user_summary_stats_empty() { + let (db, _dir) = setup().await; + let stats = db.user_summary_stats(None).await.unwrap(); + assert!(stats.is_empty()); + } + + #[tokio::test] + async fn test_user_summary_stats_with_jobs_and_costs() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + db.create_user(&test_user("bob")).await.unwrap(); + + // Alice: 2 jobs, 2 LLM calls + insert_test_job(&db, "job-a1", "alice").await; + insert_test_job(&db, "job-a2", "alice").await; + insert_test_llm_call(&db, "job-a1", "gpt-4", "0.05").await; + insert_test_llm_call(&db, "job-a2", "gpt-4", "0.10").await; + + // Bob: 1 job, no LLM calls + insert_test_job(&db, "job-b1", "bob").await; + + // All users with LLM calls (Bob has a job but no LLM calls, so no stats) + let stats = db.user_summary_stats(None).await.unwrap(); + assert_eq!(stats.len(), 1); + + let alice_stats = stats.iter().find(|s| s.user_id == "alice").unwrap(); + assert_eq!(alice_stats.job_count, 2); + assert_eq!( + alice_stats.total_cost, + rust_decimal::Decimal::from_str_exact("0.15").unwrap() + ); + assert!(alice_stats.last_active_at.is_some()); + + // Bob has no LLM calls so doesn't appear in summary stats + assert!(stats.iter().find(|s| s.user_id == "bob").is_none()); + + // Filter to single user + let alice_only = db.user_summary_stats(Some("alice")).await.unwrap(); + assert_eq!(alice_only.len(), 1); + assert_eq!(alice_only[0].job_count, 2); + + // Bob returns empty when filtered + let bob_only = db.user_summary_stats(Some("bob")).await.unwrap(); + assert!(bob_only.is_empty()); + } + + #[tokio::test] + async fn test_user_usage_stats_with_calls() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + + insert_test_job(&db, "job-a1", "alice").await; + insert_test_llm_call(&db, "job-a1", "gpt-4", "0.05").await; + insert_test_llm_call(&db, "job-a1", "gpt-4", "0.10").await; + insert_test_llm_call(&db, "job-a1", "gpt-3.5", "0.01").await; + + let since = chrono::Utc::now() - chrono::Duration::hours(1); + let stats = db.user_usage_stats(None, since).await.unwrap(); + + // Two models used + assert_eq!(stats.len(), 2); + + let gpt4 = stats.iter().find(|s| s.model == "gpt-4").unwrap(); + assert_eq!(gpt4.call_count, 2); + assert_eq!(gpt4.input_tokens, 200); + assert_eq!(gpt4.output_tokens, 100); + assert_eq!( + gpt4.total_cost, + rust_decimal::Decimal::from_str_exact("0.15").unwrap() + ); + + let gpt35 = stats.iter().find(|s| s.model == "gpt-3.5").unwrap(); + assert_eq!(gpt35.call_count, 1); + } +} diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index d0ec20ef..2a4fa5c5 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -579,6 +579,36 @@ 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', '(? 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, + pub display_name: String, + /// `active`, `suspended`, or `deactivated`. + pub status: String, + /// `admin` or `member`. + pub role: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login_at: Option>, + /// Who created/invited this user (nullable for bootstrap users). + pub created_by: Option, + 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>, + pub last_used_at: Option>, + pub created_at: DateTime, + /// Soft-revoke timestamp. Non-null means revoked. + pub revoked_at: Option>, +} + // ==================== Sub-traits ==================== // // Each sub-trait groups related persistence methods. The `Database` supertrait @@ -761,6 +798,113 @@ 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, DatabaseError>; + /// Get a user by email address. + async fn get_user_by_email(&self, email: &str) -> Result, DatabaseError>; + /// List users, optionally filtered by status. + async fn list_users(&self, status: Option<&str>) -> Result, 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>, + ) -> Result; + /// List tokens for a user (never includes the hash). + async fn list_api_tokens(&self, user_id: &str) -> Result, 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; + /// 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, 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; + + /// 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; + + /// 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, + ) -> Result, 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, 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>, + ) -> Result; +} + +/// 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>, +} + /// Backend-agnostic database supertrait. /// /// Combines all sub-traits into one. Existing `Arc` consumers @@ -774,6 +918,7 @@ pub trait Database: + ToolFailureStore + SettingsStore + WorkspaceStore + + UserStore + Send + Sync { diff --git a/src/db/postgres.rs b/src/db/postgres.rs index 9e5ea9ce..2fba0b53 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus}; use crate::config::DatabaseConfig; use crate::context::{ActionRecord, JobContext, JobState}; use crate::db::{ - ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore, - ToolFailureStore, WorkspaceStore, + ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore, + SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore, }; use crate::error::{DatabaseError, WorkspaceError}; use crate::history::{ @@ -786,3 +786,115 @@ 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, DatabaseError> { + self.store.get_user(id).await + } + + async fn get_user_by_email(&self, email: &str) -> Result, DatabaseError> { + self.store.get_user_by_email(email).await + } + + async fn list_users(&self, status: Option<&str>) -> Result, 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>, + ) -> Result { + 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, DatabaseError> { + self.store.list_api_tokens(user_id).await + } + + async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result { + self.store.revoke_api_token(token_id, user_id).await + } + + async fn authenticate_token( + &self, + token_hash: &[u8; 32], + ) -> Result, 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 { + self.store.has_any_users().await + } + + async fn delete_user(&self, id: &str) -> Result { + self.store.delete_user(id).await + } + + async fn user_usage_stats( + &self, + user_id: Option<&str>, + since: DateTime, + ) -> Result, DatabaseError> { + self.store.user_usage_stats(user_id, since).await + } + + async fn user_summary_stats( + &self, + user_id: Option<&str>, + ) -> Result, 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>, + ) -> Result { + self.store + .create_user_with_token(user, token_name, token_hash, token_prefix, expires_at) + .await + } +} diff --git a/src/db/tls.rs b/src/db/tls.rs index bbcb6c6f..37eecc6e 100644 --- a/src/db/tls.rs +++ b/src/db/tls.rs @@ -2,7 +2,8 @@ //! //! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector //! based on the configured [`SslMode`]. Uses `rustls` with system root -//! certificates — the same TLS stack that `reqwest` already uses for HTTP. +//! certificates, falling back to Mozilla's bundled roots via `webpki-roots` +//! when the system store is empty (common in minimal container images). use deadpool_postgres::{Pool, Runtime}; use thiserror::Error; @@ -19,9 +20,15 @@ pub enum CreatePoolError { TlsConfig(#[from] rustls::Error), } -/// Build a rustls-based TLS connector using the platform's root certificate store. +/// 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`. fn make_rustls_connector() -> Result { 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}"); @@ -31,11 +38,14 @@ fn make_rustls_connector() -> Result { 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::error!("no system root certificates found -- TLS connections will fail"); + tracing::info!("no system root certificates found, using bundled Mozilla roots"); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); } - // `--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. + + // Pick the ring crypto provider (same one reqwest uses). let config = rustls::ClientConfig::builder_with_provider( rustls::crypto::ring::default_provider().into(), ) @@ -48,7 +58,7 @@ fn make_rustls_connector() -> Result { /// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector. /// /// - `Disable` → plain TCP (no TLS) -/// - `Prefer` / `Require` → rustls with system root certificates +/// - `Prefer` / `Require` → rustls with system or bundled root certificates /// /// **Note:** `Prefer` and `Require` currently behave identically — both /// provide a TLS connector and will fail if the server rejects the TLS @@ -81,7 +91,6 @@ 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()); } diff --git a/src/history/store.rs b/src/history/store.rs index 625e8b1e..e6e869b6 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -2279,6 +2279,546 @@ 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, 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, 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, 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>, + ) -> Result { + 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>, + ) -> Result { + 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, 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 { + 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, 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 { + 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 { + 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, + ) -> Result, 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, 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::*; diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index 038236fd..bfc6c567 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -601,11 +601,14 @@ 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. 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. +/// `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. fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) { let Some(model) = model_override else { return; @@ -1515,4 +1518,51 @@ mod tests { "different raw IDs should produce different hashed IDs" ); } + + fn make_rig_request(additional_params: Option) -> 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()); + } } diff --git a/src/main.rs b/src/main.rs index 3a43ce0d..88bf76c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -591,27 +591,7 @@ async fn async_main() -> anyhow::Result<()> { let mut gateway_url: Option = None; let mut sse_manager: Option> = None; if let Some(ref gw_config) = config.channels.gateway { - // 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()); + let mut gw = GatewayChannel::new(gw_config.clone(), 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)); @@ -650,6 +630,54 @@ 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)); @@ -791,12 +819,7 @@ async fn async_main() -> anyhow::Result<()> { .await; // Default user ID for extension operations (single-user mode). - let ext_user_id = config - .channels - .gateway - .as_ref() - .map(|g| g.user_id.clone()) - .unwrap_or_else(|| "default".to_string()); + let ext_user_id = config.owner_id.clone(); // Wire up channel runtime for hot-activation of WASM channels. if let Some(ref ext_mgr) = components.extension_manager diff --git a/src/settings.rs b/src/settings.rs index 1bb1a8f7..09d9d9d0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -269,10 +269,6 @@ pub struct ChannelSettings { #[serde(default)] pub gateway_auth_token: Option, - /// Web gateway user ID. - #[serde(default)] - pub gateway_user_id: Option, - /// Whether the CLI channel is enabled. #[serde(default = "default_true")] pub cli_enabled: bool, @@ -342,7 +338,6 @@ 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, diff --git a/src/tenant.rs b/src/tenant.rs index 19b0946f..b8af5d84 100644 --- a/src/tenant.rs +++ b/src/tenant.rs @@ -209,6 +209,13 @@ 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 { + self.inner.record_llm_call(record).await + } + // === Settings === pub async fn get_setting(&self, key: &str) -> Result, DatabaseError> { @@ -330,35 +337,62 @@ impl TenantScope { /// Add a message to a conversation owned by this tenant. /// - /// Verifies the conversation belongs to this user before adding. + /// Returns `NotFound` if the conversation does not belong to this user. pub async fn add_conversation_message( &self, conversation_id: Uuid, role: &str, content: &str, ) -> Result { + 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, 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>, limit: i64, ) -> Result<(Vec, 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 @@ -374,21 +408,35 @@ 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, 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 } } diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index 8719b6e1..e73fcd46 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -428,10 +428,8 @@ 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 } @@ -442,10 +440,8 @@ 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 } diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index aa8ba1cb..9f4b02f3 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -271,7 +271,6 @@ 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, @@ -371,7 +370,6 @@ 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, @@ -411,7 +409,6 @@ 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: @@ -473,7 +470,6 @@ 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", diff --git a/tests/multi_tenant_integration.rs b/tests/multi_tenant_integration.rs index 227fa721..8f1682a0 100644 --- a/tests/multi_tenant_integration.rs +++ b/tests/multi_tenant_integration.rs @@ -42,8 +42,6 @@ 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(); @@ -51,6 +49,7 @@ 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(), }, ); @@ -58,6 +57,7 @@ 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,7 +79,10 @@ 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(auth, auth_middleware)) + .layer(middleware::from_fn_with_state( + ironclaw::channels::web::auth::CombinedAuthState::from(auth), + auth_middleware, + )) } // =========================================================================== @@ -544,7 +547,6 @@ 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, @@ -558,10 +560,11 @@ 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()); } @@ -592,6 +595,7 @@ 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(), }, ); @@ -599,6 +603,7 @@ 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(), }, ); @@ -618,7 +623,6 @@ 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, @@ -632,9 +636,11 @@ 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); + let auth = MultiAuthState::multi(tokens).into(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let bound = start_server(addr, state.clone(), auth) .await @@ -769,7 +775,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, GATEWAY_SENDER_ID); + assert_eq!(owner_msg.sender_id, OWNER_SCOPE_ID); assert_eq!(owner_msg.content, "hello from owner"); let other_resp = client @@ -1003,7 +1009,6 @@ 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, @@ -1017,10 +1022,12 @@ 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) + let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into()) .await .expect("Failed to start server with DB"); diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index b677e57f..8ac0a0ac 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -205,7 +205,6 @@ 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), @@ -219,6 +218,8 @@ 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( @@ -226,7 +227,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) + let bound_addr = start_server(addr, state.clone(), auth.into()) .await .expect("Failed to start test server"); @@ -704,7 +705,6 @@ 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,6 +718,8 @@ 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( @@ -725,7 +727,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).await.unwrap(); + let bound_addr = start_server(addr, state, auth.into()).await.unwrap(); let url = format!("http://{}/v1/chat/completions", bound_addr); let resp = client() @@ -763,7 +765,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( - auth_state, + ironclaw::channels::web::auth::CombinedAuthState::from(auth_state), ironclaw::channels::web::auth::auth_middleware, )) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index ac35b160..d6341704 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -227,7 +227,6 @@ 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)), @@ -241,6 +240,8 @@ 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( @@ -299,7 +300,7 @@ impl GatewayWorkflowHarness { let addr = start_server( "127.0.0.1:0".parse().expect("valid localhost addr"), Arc::clone(&gateway_state), - auth, + auth.into(), ) .await .expect("failed to start gateway server"); diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index 0ec5c929..0a877575 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -52,7 +52,6 @@ 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, @@ -66,6 +65,8 @@ 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( @@ -73,7 +74,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) + let bound_addr = start_server(addr, state.clone(), auth.into()) .await .expect("Failed to start test server");