mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f970252d5 | ||
|
|
3930184e71 | ||
|
|
4fb0d53206 | ||
|
|
5f50df7583 | ||
|
|
30feaabb7c | ||
|
|
427f908e71 | ||
|
|
8bd30a970e | ||
|
|
5d1d504e11 | ||
|
|
9bb19a98f7 | ||
|
|
0b33ca9926 | ||
|
|
9ba10eac35 | ||
|
|
27e8d6f8dd | ||
|
|
f49f368355 | ||
|
|
8f8cb7f7b1 |
Generated
+2
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+34
-8
@@ -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
|
||||
|
||||
@@ -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 <token>`. Tokens are either:
|
||||
- **Env-var tokens** — configured via `GATEWAY_AUTH_TOKEN` (single-user) at startup
|
||||
- **DB-backed tokens** — created via `POST /api/tokens` or `POST /api/admin/users`
|
||||
|
||||
DB tokens are SHA-256 hashed at rest; plaintext is returned exactly once at creation time.
|
||||
|
||||
Auth is cached in a bounded LRU (1024 entries, 60s TTL). Suspending a user or revoking a token may take up to 60s to take effect.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Scope |
|
||||
|------|-------|
|
||||
| `admin` | Full access to all endpoints |
|
||||
| `member` | Self-service profile + own token management only |
|
||||
|
||||
Endpoints marked **Admin** return `403 Forbidden` for `member` role.
|
||||
|
||||
---
|
||||
|
||||
## Admin: Users
|
||||
|
||||
### POST /api/admin/users
|
||||
|
||||
Create a new user. Returns the user record and a one-time plaintext API token.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "Alice Smith",
|
||||
"email": "[email protected]",
|
||||
"role": "member"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `display_name` | string | yes | | |
|
||||
| `email` | string | no | `null` | Must be unique if provided |
|
||||
| `role` | string | no | `"member"` | `"admin"` or `"member"` |
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "[email protected]",
|
||||
"display_name": "Alice Smith",
|
||||
"status": "active",
|
||||
"role": "member",
|
||||
"token": "a1b2c3d4e5f6...64-char hex...",
|
||||
"created_at": "2026-03-25T12:00:00+00:00",
|
||||
"created_by": "admin-user-id"
|
||||
}
|
||||
```
|
||||
|
||||
The `token` field is the plaintext API token. It is shown **only once** — store it securely.
|
||||
|
||||
**Errors:** `400` (missing display_name, invalid role), `403` (not admin), `503` (no database)
|
||||
|
||||
---
|
||||
|
||||
### GET /api/admin/users
|
||||
|
||||
List all users.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"email": "[email protected]",
|
||||
"display_name": "Alice Smith",
|
||||
"status": "active",
|
||||
"role": "member",
|
||||
"created_at": "2026-03-25T12:00:00+00:00",
|
||||
"updated_at": "2026-03-25T12:00:00+00:00",
|
||||
"last_login_at": "2026-03-25T14:30:00+00:00",
|
||||
"created_by": "admin-user-id"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/admin/users/{id}
|
||||
|
||||
Get a single user by ID.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"email": "[email protected]",
|
||||
"display_name": "Alice Smith",
|
||||
"status": "active",
|
||||
"role": "member",
|
||||
"created_at": "2026-03-25T12:00:00+00:00",
|
||||
"updated_at": "2026-03-25T12:00:00+00:00",
|
||||
"last_login_at": "2026-03-25T14:30:00+00:00",
|
||||
"created_by": "admin-user-id",
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` (user not found), `403` (not admin)
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/admin/users/{id}
|
||||
|
||||
Update a user's display name and/or metadata. Omitted fields are left unchanged.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "Alice Johnson",
|
||||
"metadata": {"department": "engineering"}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `display_name` | string | no | |
|
||||
| `role` | string | no | `"admin"` or `"member"` |
|
||||
| `metadata` | object | no | Replaces entire metadata object (full replacement; keys not included are removed) |
|
||||
|
||||
**Response:** `200 OK` — returns the full updated user record (same shape as GET detail, without `last_login_at`/`created_by`).
|
||||
|
||||
**Errors:** `404` (user not found), `403` (not admin)
|
||||
|
||||
---
|
||||
|
||||
### POST /api/admin/users/{id}/suspend
|
||||
|
||||
Suspend a user. Suspended users cannot authenticate (DB auth checks user status).
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"status": "suspended"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` (user not found), `403` (not admin)
|
||||
|
||||
---
|
||||
|
||||
### POST /api/admin/users/{id}/activate
|
||||
|
||||
Re-activate a suspended user.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` (user not found), `403` (not admin)
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/admin/users/{id}
|
||||
|
||||
Permanently delete a user and all associated data (tokens, jobs, conversations, memory, routines, settings, secrets).
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"deleted": true
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` (user not found), `403` (not admin)
|
||||
|
||||
**Cascade:** Deletes from `api_tokens`, `agent_jobs`, `conversations`, `memory_documents`, `routines`, `secrets`, `settings`, `wasm_tools`, and related tables. On PostgreSQL this uses FK cascades; on libSQL it uses explicit deletes.
|
||||
|
||||
---
|
||||
|
||||
## Admin: Per-User Secrets
|
||||
|
||||
Provision secrets on behalf of individual users. The primary use case is an application backend (acting as admin) that configures per-user credentials so each user's IronClaw agent can call back to external services.
|
||||
|
||||
Secrets are encrypted at rest with AES-256-GCM using a per-secret HKDF-derived key. Plaintext values are **never returned** by any endpoint — they can only be used by the agent's tool system at runtime.
|
||||
|
||||
### PUT /api/admin/users/{user_id}/secrets/{name}
|
||||
|
||||
Create or update a secret for the specified user. If a secret with the same name already exists, it is overwritten.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Path parameters:**
|
||||
|
||||
| Param | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `user_id` | string | The user's ID |
|
||||
| `name` | string | Secret name (normalized to lowercase) |
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"value": "sk-live-abc123...",
|
||||
"provider": "my-app-backend",
|
||||
"expires_in_days": 90
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `value` | string | yes | The secret value (encrypted at rest, never returned) |
|
||||
| `provider` | string | no | Tag for grouping (e.g. `"stripe"`, `"my-app"`) |
|
||||
| `expires_in_days` | integer | no | Auto-expire after N days; `null` = never |
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "550e8400-...",
|
||||
"name": "my_app_callback_token",
|
||||
"status": "created"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `400` (missing value), `403` (not admin), `503` (secrets store not available)
|
||||
|
||||
**Example — application backend provisioning a callback token:**
|
||||
|
||||
```bash
|
||||
# Admin creates a user
|
||||
curl -X POST https://ironclaw.example.com/api/admin/users \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-d '{"display_name": "Alice", "role": "member"}'
|
||||
# Response includes: {"id": "alice-uuid", "token": "alice-bearer-token", ...}
|
||||
|
||||
# Admin provisions a per-user callback secret
|
||||
curl -X PUT https://ironclaw.example.com/api/admin/users/alice-uuid/secrets/app_callback_token \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-d '{"value": "per-user-jwt-for-alice", "provider": "my-app"}'
|
||||
|
||||
# Now Alice's IronClaw agent can use the "app_callback_token" secret
|
||||
# when calling tools that need to authenticate back to the app backend.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/admin/users/{user_id}/secrets
|
||||
|
||||
List a user's secrets. Returns names and providers only — **never values or hashes**.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "550e8400-...",
|
||||
"secrets": [
|
||||
{"name": "app_callback_token", "provider": "my-app"},
|
||||
{"name": "openai_api_key", "provider": "openai"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/admin/users/{user_id}/secrets/{name}
|
||||
|
||||
Delete a specific secret for a user.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "550e8400-...",
|
||||
"name": "app_callback_token",
|
||||
"deleted": true
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` (secret not found), `403` (not admin), `503` (secrets store not available)
|
||||
|
||||
---
|
||||
|
||||
## Admin: Usage
|
||||
|
||||
### GET /api/admin/usage
|
||||
|
||||
Per-user LLM usage statistics aggregated from `llm_calls` via `agent_jobs.user_id`.
|
||||
|
||||
**Auth:** Admin
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `user_id` | string | all users | Filter to a single user |
|
||||
| `period` | string | `"day"` | `"day"` (24h), `"week"` (7d), or `"month"` (30d) |
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"period": "week",
|
||||
"since": "2026-03-18T12:00:00+00:00",
|
||||
"usage": [
|
||||
{
|
||||
"user_id": "alice-id",
|
||||
"model": "claude-sonnet-4-5-20250514",
|
||||
"call_count": 42,
|
||||
"input_tokens": 150000,
|
||||
"output_tokens": 30000,
|
||||
"total_cost": "1.23"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Service: Profile
|
||||
|
||||
### GET /api/profile
|
||||
|
||||
Get the authenticated user's own profile.
|
||||
|
||||
**Auth:** Any authenticated user
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"email": "[email protected]",
|
||||
"display_name": "Alice Smith",
|
||||
"status": "active",
|
||||
"role": "member",
|
||||
"created_at": "2026-03-25T12:00:00+00:00",
|
||||
"last_login_at": "2026-03-25T14:30:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/profile
|
||||
|
||||
Update the authenticated user's own display name and/or metadata.
|
||||
|
||||
**Auth:** Any authenticated user
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "Alice Johnson",
|
||||
"metadata": {"theme": "dark"}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-...",
|
||||
"display_name": "Alice Johnson",
|
||||
"updated": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Service: Tokens
|
||||
|
||||
### POST /api/tokens
|
||||
|
||||
Create a new API token for the authenticated user. Admins can optionally create tokens for other users by including `user_id`.
|
||||
|
||||
**Auth:** Any authenticated user
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "CI pipeline",
|
||||
"expires_in_days": 90,
|
||||
"user_id": "other-user-id"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `name` | string | yes | Human-readable label |
|
||||
| `expires_in_days` | integer | no | `null` = never expires |
|
||||
| `user_id` | string | no | Admin-only; create token for another user |
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "a1b2c3d4...64-char hex...",
|
||||
"id": "token-uuid",
|
||||
"name": "CI pipeline",
|
||||
"token_prefix": "a1b2c3d4",
|
||||
"expires_at": "2026-06-23T12:00:00+00:00",
|
||||
"created_at": "2026-03-25T12:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
The `token` field is shown **only once**.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/tokens
|
||||
|
||||
List the authenticated user's tokens. Token hashes are never returned.
|
||||
|
||||
**Auth:** Any authenticated user
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": [
|
||||
{
|
||||
"id": "token-uuid",
|
||||
"name": "CI pipeline",
|
||||
"token_prefix": "a1b2c3d4",
|
||||
"expires_at": "2026-06-23T12:00:00+00:00",
|
||||
"last_used_at": "2026-03-25T14:00:00+00:00",
|
||||
"created_at": "2026-03-25T12:00:00+00:00",
|
||||
"revoked_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/tokens/{id}
|
||||
|
||||
Revoke one of the authenticated user's tokens. Users can only revoke their own tokens.
|
||||
|
||||
**Auth:** Any authenticated user
|
||||
|
||||
**Path:** `id` — UUID of the token to revoke
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "revoked",
|
||||
"id": "token-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `400` (invalid UUID), `404` (token not found or belongs to another user)
|
||||
|
||||
---
|
||||
|
||||
## Error Format
|
||||
|
||||
All error responses return a plain text body with the error message and the corresponding HTTP status code:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `400` | Bad request (missing fields, invalid input) |
|
||||
| `401` | Missing or invalid bearer token |
|
||||
| `403` | Authenticated but insufficient role (member accessing admin endpoint) |
|
||||
| `404` | Resource not found |
|
||||
| `503` | Database or secrets store not available |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
### Secrets Encryption
|
||||
|
||||
- **Algorithm:** AES-256-GCM with per-secret HKDF-SHA256 derived keys
|
||||
- **Master key:** 32+ bytes, resolved from `SECRETS_MASTER_KEY` env var or OS keychain
|
||||
- **Storage format:** `nonce (12B) || ciphertext || tag (16B)` in `encrypted_value` column
|
||||
- **Per-secret salt:** 32 random bytes stored alongside the ciphertext
|
||||
- **Zero-exposure:** Plaintext never appears in logs, debug output, API responses, or LLM conversations
|
||||
|
||||
### Auth Cache
|
||||
|
||||
- Bounded LRU cache (1024 entries max)
|
||||
- 60-second TTL per entry
|
||||
- Suspending a user or revoking a token takes up to 60s to propagate
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### users
|
||||
|
||||
| Column | Type (PG / libSQL) | Notes |
|
||||
|--------|--------------------|-------|
|
||||
| `id` | `TEXT` / `TEXT` | Primary key; typically UUID v4 strings (bootstrap admin may use a custom ID) |
|
||||
| `email` | `TEXT UNIQUE` | Nullable |
|
||||
| `display_name` | `TEXT NOT NULL` | |
|
||||
| `status` | `TEXT NOT NULL` | `"active"` or `"suspended"` |
|
||||
| `role` | `TEXT NOT NULL` | `"admin"` or `"member"` |
|
||||
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
|
||||
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
|
||||
| `last_login_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
||||
| `created_by` | `TEXT` | Nullable, references `users.id` |
|
||||
| `metadata` | `JSONB` / `TEXT` | Default `{}` |
|
||||
|
||||
### api_tokens
|
||||
|
||||
| Column | Type (PG / libSQL) | Notes |
|
||||
|--------|--------------------|-------|
|
||||
| `id` | `UUID` / `TEXT` | Primary key |
|
||||
| `user_id` | `TEXT NOT NULL` | FK to `users.id` (PG cascades; libSQL explicit cleanup) |
|
||||
| `token_hash` | `BYTEA` / `BLOB` | SHA-256 of hex-encoded plaintext |
|
||||
| `token_prefix` | `TEXT NOT NULL` | First 8 chars for identification |
|
||||
| `name` | `TEXT NOT NULL` | Human-readable label |
|
||||
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
||||
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
||||
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
|
||||
| `revoked_at` | `TIMESTAMPTZ` / `TEXT` | Nullable; set on revocation |
|
||||
|
||||
### secrets
|
||||
|
||||
| Column | Type (PG / libSQL) | Notes |
|
||||
|--------|--------------------|-------|
|
||||
| `id` | `UUID` / `TEXT` | Primary key |
|
||||
| `user_id` | `TEXT NOT NULL` | Scoped to user |
|
||||
| `name` | `TEXT NOT NULL` | Unique per user (lowercase normalized) |
|
||||
| `encrypted_value` | `BYTEA` / `BLOB` | AES-256-GCM (nonce + ciphertext + tag) |
|
||||
| `key_salt` | `BYTEA` / `BLOB` | Per-secret HKDF salt |
|
||||
| `provider` | `TEXT` | Optional grouping tag |
|
||||
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
||||
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Audit: last injection time |
|
||||
| `usage_count` | `BIGINT` / `INTEGER` | Audit: total injections |
|
||||
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
|
||||
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add source_channel to conversations for cross-channel approval authorization.
|
||||
-- Tracks which channel originally created a conversation so that approval
|
||||
-- messages from other channels can be validated.
|
||||
ALTER TABLE conversations ADD COLUMN source_channel TEXT;
|
||||
@@ -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);
|
||||
+80
-7
@@ -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(
|
||||
@@ -831,7 +843,10 @@ impl Agent {
|
||||
{
|
||||
use crate::agent::session::Thread;
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(id, sess.id);
|
||||
// Bootstrap thread has no incoming message -- use the
|
||||
// "__bootstrap__" sentinel so approvals from any channel are
|
||||
// permitted. None means "deny by default" (fail-closed).
|
||||
let thread = Thread::with_id(id, sess.id, Some("__bootstrap__"));
|
||||
sess.active_thread = Some(id);
|
||||
sess.threads.entry(id).or_insert(thread);
|
||||
}
|
||||
@@ -1136,7 +1151,37 @@ impl Agent {
|
||||
.get_or_create_session(&message.user_id)
|
||||
.await;
|
||||
let mut sess = session.lock().await;
|
||||
if sess.threads.contains_key(&target_thread_id) {
|
||||
if let Some(thread) = sess.threads.get(&target_thread_id) {
|
||||
// Verify the thread actually has a pending approval before
|
||||
// allowing approval-shaped messages to target it. Without this
|
||||
// check, an attacker could use approval messages to hijack any
|
||||
// thread by UUID.
|
||||
if thread.pending_approval.is_none() {
|
||||
tracing::warn!(
|
||||
%target_thread_id,
|
||||
approval_channel = %message.channel,
|
||||
"Blocked approval for thread with no pending approval"
|
||||
);
|
||||
drop(sess);
|
||||
return Ok(Some("Error: no pending approval on this thread".into()));
|
||||
}
|
||||
|
||||
let authorized = crate::agent::session::is_approval_authorized(
|
||||
thread.source_channel.as_deref(),
|
||||
&message.channel,
|
||||
);
|
||||
if !authorized {
|
||||
tracing::warn!(
|
||||
%target_thread_id,
|
||||
source_channel = ?thread.source_channel,
|
||||
approval_channel = %message.channel,
|
||||
"Blocked cross-channel approval attempt"
|
||||
);
|
||||
drop(sess);
|
||||
return Ok(Some(
|
||||
"Error: approval not authorized for this channel".into(),
|
||||
));
|
||||
}
|
||||
sess.active_thread = Some(target_thread_id);
|
||||
sess.last_active_at = chrono::Utc::now();
|
||||
drop(sess);
|
||||
@@ -1263,6 +1308,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
|
||||
|
||||
@@ -319,7 +319,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("Hello");
|
||||
thread.complete_turn("Hi there");
|
||||
thread.start_turn("How are you?");
|
||||
@@ -351,7 +351,7 @@ mod tests {
|
||||
/// Helper: build a thread with `n` completed turns.
|
||||
/// Turn `i` has user_input "msg-{i}" and response "resp-{i}".
|
||||
fn make_thread(n: usize) -> Thread {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
for i in 0..n {
|
||||
thread.start_turn(format!("msg-{}", i));
|
||||
thread.complete_turn(format!("resp-{}", i));
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
async fn test_compact_truncate_empty_turns() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
assert!(thread.turns.is_empty());
|
||||
|
||||
let result = compactor
|
||||
@@ -698,7 +698,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_with_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("Search for X");
|
||||
// Record a tool call on the current turn
|
||||
if let Some(turn) = thread.turns.last_mut() {
|
||||
@@ -719,7 +719,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_incomplete_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("In progress message");
|
||||
// Don't complete the turn
|
||||
|
||||
|
||||
+37
-11
@@ -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,
|
||||
@@ -2274,7 +2299,7 @@ mod tests {
|
||||
// Initialize a thread in the session so the loop can record tool calls.
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
sess.create_thread().id
|
||||
sess.create_thread(Some("test")).id
|
||||
};
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "do something");
|
||||
@@ -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,
|
||||
@@ -2386,7 +2412,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("test-user")));
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
sess.create_thread().id
|
||||
sess.create_thread(Some("test")).id
|
||||
};
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "keep calling tools");
|
||||
|
||||
+25
-23
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+169
-54
@@ -68,8 +68,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Create a new thread in this session.
|
||||
pub fn create_thread(&mut self) -> &mut Thread {
|
||||
let thread = Thread::new(self.id);
|
||||
pub fn create_thread(&mut self, channel: Option<&str>) -> &mut Thread {
|
||||
let thread = Thread::new(self.id, channel);
|
||||
let thread_id = thread.id;
|
||||
self.active_thread = Some(thread_id);
|
||||
self.last_active_at = Utc::now();
|
||||
@@ -87,9 +87,9 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Get or create the active thread.
|
||||
pub fn get_or_create_thread(&mut self) -> &mut Thread {
|
||||
pub fn get_or_create_thread(&mut self, channel: Option<&str>) -> &mut Thread {
|
||||
match self.active_thread {
|
||||
None => self.create_thread(),
|
||||
None => self.create_thread(channel),
|
||||
Some(id) => {
|
||||
if self.threads.contains_key(&id) {
|
||||
// Entry existence confirmed by contains_key above.
|
||||
@@ -100,7 +100,7 @@ impl Session {
|
||||
} else {
|
||||
// Stale active_thread ID: create a new thread, which
|
||||
// updates self.active_thread to the new thread's ID.
|
||||
self.create_thread()
|
||||
self.create_thread(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,9 @@ pub struct Thread {
|
||||
/// Messages queued while the thread was processing a turn.
|
||||
#[serde(default, skip_serializing_if = "VecDeque::is_empty")]
|
||||
pub pending_messages: VecDeque<String>,
|
||||
/// Channel that created this thread (for approval authorization).
|
||||
#[serde(default)]
|
||||
pub source_channel: Option<String>,
|
||||
}
|
||||
|
||||
/// Maximum number of messages that can be queued while a thread is processing.
|
||||
@@ -233,9 +236,29 @@ pub struct Thread {
|
||||
/// rapid follow-ups. The drain loop processes them as one newline-delimited turn.
|
||||
pub const MAX_PENDING_MESSAGES: usize = 10;
|
||||
|
||||
/// Sentinel value for bootstrap threads that accept approvals from any channel.
|
||||
pub const BOOTSTRAP_SOURCE_CHANNEL: &str = "__bootstrap__";
|
||||
|
||||
/// Check whether an approval from `requesting_channel` is authorized for a
|
||||
/// thread whose `source_channel` is `source`.
|
||||
///
|
||||
/// Rules:
|
||||
/// - `None` (unknown origin) -> denied (fail-closed)
|
||||
/// - `Some("__bootstrap__")` -> authorized from any channel
|
||||
/// - `Some(src) == requesting` -> same channel, authorized
|
||||
/// - requesting is "web" or "gateway" -> always authorized (trusted UI)
|
||||
/// - Otherwise -> denied
|
||||
pub fn is_approval_authorized(source: Option<&str>, requesting: &str) -> bool {
|
||||
match source {
|
||||
None => false,
|
||||
Some(src) if src == BOOTSTRAP_SOURCE_CHANNEL => true,
|
||||
Some(src) => src == requesting || requesting == "web" || requesting == "gateway",
|
||||
}
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
/// Create a new thread.
|
||||
pub fn new(session_id: Uuid) -> Self {
|
||||
pub fn new(session_id: Uuid, source_channel: Option<&str>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
@@ -248,11 +271,12 @@ impl Thread {
|
||||
pending_approval: None,
|
||||
pending_auth: None,
|
||||
pending_messages: VecDeque::new(),
|
||||
source_channel: source_channel.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a thread with a specific ID (for DB hydration).
|
||||
pub fn with_id(id: Uuid, session_id: Uuid) -> Self {
|
||||
pub fn with_id(id: Uuid, session_id: Uuid, source_channel: Option<&str>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id,
|
||||
@@ -265,6 +289,7 @@ impl Thread {
|
||||
pending_approval: None,
|
||||
pending_auth: None,
|
||||
pending_messages: VecDeque::new(),
|
||||
source_channel: source_channel.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,13 +812,13 @@ mod tests {
|
||||
let mut session = Session::new("user-123");
|
||||
assert!(session.active_thread.is_none());
|
||||
|
||||
session.create_thread();
|
||||
session.create_thread(None);
|
||||
assert!(session.active_thread.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Hello");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
@@ -806,7 +831,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("First message");
|
||||
thread.complete_turn("First response");
|
||||
@@ -829,7 +854,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// First add some turns
|
||||
thread.start_turn("Original message");
|
||||
@@ -855,7 +880,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_incomplete_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Messages with incomplete last turn (no assistant response)
|
||||
let messages = vec![
|
||||
@@ -874,7 +899,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_enter_auth_mode() {
|
||||
let before = Utc::now();
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
assert!(thread.pending_auth.is_none());
|
||||
|
||||
thread.enter_auth_mode("telegram".to_string());
|
||||
@@ -887,7 +912,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_take_pending_auth() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.enter_auth_mode("notion".to_string());
|
||||
|
||||
let pending = thread.take_pending_auth();
|
||||
@@ -902,7 +927,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pending_auth_serialization() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.enter_auth_mode("openai".to_string());
|
||||
|
||||
let json = serde_json::to_string(&thread).expect("should serialize");
|
||||
@@ -932,7 +957,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_pending_auth_default_none() {
|
||||
// Deserialization of old data without pending_auth should default to None
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.pending_auth = None;
|
||||
let json = serde_json::to_string(&thread).expect("serialize");
|
||||
|
||||
@@ -946,7 +971,7 @@ mod tests {
|
||||
fn test_thread_with_id() {
|
||||
let specific_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread = Thread::with_id(specific_id, session_id);
|
||||
let thread = Thread::with_id(specific_id, session_id, None);
|
||||
|
||||
assert_eq!(thread.id, specific_id);
|
||||
assert_eq!(thread.session_id, session_id);
|
||||
@@ -958,7 +983,7 @@ mod tests {
|
||||
fn test_thread_with_id_restore_messages() {
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::user("Hello from DB"),
|
||||
@@ -977,7 +1002,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_empty() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Add a turn first, then restore with empty vec
|
||||
thread.start_turn("hello");
|
||||
@@ -993,7 +1018,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_only_assistant_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Only assistant messages (no user messages to anchor turns)
|
||||
let messages = vec![
|
||||
@@ -1010,7 +1035,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_multiple_user_messages_in_a_row() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Two user messages with no assistant response between them
|
||||
let messages = vec![
|
||||
@@ -1037,8 +1062,8 @@ mod tests {
|
||||
fn test_thread_switch() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
let t1_id = session.create_thread().id;
|
||||
let t2_id = session.create_thread().id;
|
||||
let t1_id = session.create_thread(None).id;
|
||||
let t2_id = session.create_thread(None).id;
|
||||
|
||||
// After creating two threads, active should be the last one
|
||||
assert_eq!(session.active_thread, Some(t2_id));
|
||||
@@ -1058,8 +1083,8 @@ mod tests {
|
||||
fn test_get_or_create_thread_idempotent() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
let tid1 = session.get_or_create_thread().id;
|
||||
let tid2 = session.get_or_create_thread().id;
|
||||
let tid1 = session.get_or_create_thread(None).id;
|
||||
let tid2 = session.get_or_create_thread(None).id;
|
||||
|
||||
// Should return the same thread (not create a new one each time)
|
||||
assert_eq!(tid1, tid2);
|
||||
@@ -1068,7 +1093,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_truncate_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
for i in 0..5 {
|
||||
thread.start_turn(format!("msg-{}", i));
|
||||
@@ -1092,7 +1117,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_truncate_turns_noop_when_fewer() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("only one");
|
||||
thread.complete_turn("response");
|
||||
@@ -1104,7 +1129,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_interrupt_and_resume() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("do something");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
@@ -1122,7 +1147,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_resume_only_from_interrupted() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Idle thread: resume should be a no-op
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
@@ -1138,7 +1163,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_turn_fail() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("risky operation");
|
||||
thread.fail_turn("connection timed out");
|
||||
@@ -1154,7 +1179,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_with_incomplete_last_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("first");
|
||||
thread.complete_turn("first reply");
|
||||
@@ -1170,7 +1195,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_serialization_round_trip() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("hello");
|
||||
thread.complete_turn("world");
|
||||
@@ -1188,7 +1213,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_session_serialization_round_trip() {
|
||||
let mut session = Session::new("user-ser");
|
||||
session.create_thread();
|
||||
session.create_thread(None);
|
||||
session.auto_approve_tool("echo");
|
||||
|
||||
let json = serde_json::to_string(&session).unwrap();
|
||||
@@ -1226,7 +1251,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_turn_number_increments() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Before any turns, turn_number() is 1 (1-indexed for display)
|
||||
assert_eq!(thread.turn_number(), 1);
|
||||
@@ -1241,7 +1266,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_complete_turn_on_empty_thread() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Completing a turn when there are no turns should be a safe no-op
|
||||
thread.complete_turn("phantom response");
|
||||
@@ -1251,7 +1276,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_fail_turn_on_empty_thread() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Failing a turn when there are no turns should be a safe no-op
|
||||
thread.fail_turn("phantom error");
|
||||
@@ -1261,7 +1286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pending_approval_flow() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let approval = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
@@ -1288,7 +1313,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_clear_pending_approval() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let approval = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
@@ -1317,7 +1342,7 @@ mod tests {
|
||||
assert!(session.active_thread().is_none());
|
||||
assert!(session.active_thread_mut().is_none());
|
||||
|
||||
let tid = session.create_thread().id;
|
||||
let tid = session.create_thread(None).id;
|
||||
|
||||
assert!(session.active_thread().is_some());
|
||||
assert_eq!(session.active_thread().unwrap().id, tid);
|
||||
@@ -1334,7 +1359,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_includes_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Search for X");
|
||||
{
|
||||
@@ -1366,7 +1391,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_multiple_tool_calls_per_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Do two things");
|
||||
{
|
||||
@@ -1393,7 +1418,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_with_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Build a message sequence with tool calls
|
||||
let tc = ToolCall {
|
||||
@@ -1425,7 +1450,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_with_tool_error() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let tc = ToolCall {
|
||||
id: "call_0".to_string(),
|
||||
@@ -1456,7 +1481,7 @@ mod tests {
|
||||
fn test_messages_round_trip_with_tools() {
|
||||
// Build a thread with tool calls, get messages(), restore, get messages() again
|
||||
// The two message sequences should be equivalent.
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Do search");
|
||||
{
|
||||
@@ -1469,7 +1494,7 @@ mod tests {
|
||||
let messages_original = thread.messages();
|
||||
|
||||
// Restore into a new thread
|
||||
let mut thread2 = Thread::new(Uuid::new_v4());
|
||||
let mut thread2 = Thread::new(Uuid::new_v4(), None);
|
||||
thread2.restore_from_messages(messages_original.clone());
|
||||
|
||||
let messages_restored = thread2.messages();
|
||||
@@ -1491,7 +1516,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_multi_stage_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let tc1 = ToolCall {
|
||||
id: "call_a".to_string(),
|
||||
@@ -1534,7 +1559,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_truncates_large_tool_results() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Read big file");
|
||||
{
|
||||
@@ -1557,7 +1582,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_message_queue() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Queue is initially empty
|
||||
assert!(thread.pending_messages.is_empty());
|
||||
@@ -1593,7 +1618,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_message_queue_serialization() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Empty queue should not appear in serialization (skip_serializing_if)
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
@@ -1613,7 +1638,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_thread_message_queue_default_on_old_data() {
|
||||
// Deserialization of old data without pending_messages should default to empty
|
||||
let thread = Thread::new(Uuid::new_v4());
|
||||
let thread = Thread::new(Uuid::new_v4(), None);
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
|
||||
// The field is absent (skip_serializing_if), simulating old data
|
||||
@@ -1624,7 +1649,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_interrupt_clears_pending_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Start a turn so there's something to interrupt
|
||||
thread.start_turn("initial input");
|
||||
@@ -1643,7 +1668,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_state_idle_after_full_drain() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Simulate a full drain cycle: start turn, queue messages, complete turn,
|
||||
// then drain all queued messages as a single merged turn (#259).
|
||||
@@ -1671,7 +1696,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_drain_pending_messages_merges_with_newlines() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Empty queue returns None
|
||||
assert!(thread.drain_pending_messages().is_none());
|
||||
@@ -1700,7 +1725,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_requeue_drained_preserves_content_at_front() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Re-queue into empty queue
|
||||
thread.requeue_drained("failed batch".to_string());
|
||||
@@ -1811,4 +1836,94 @@ mod tests {
|
||||
&serde_json::json!("done")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_new_stores_source_channel() {
|
||||
let thread = Thread::new(Uuid::new_v4(), Some("telegram"));
|
||||
assert_eq!(thread.source_channel.as_deref(), Some("telegram"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_new_none_channel() {
|
||||
let thread = Thread::new(Uuid::new_v4(), None);
|
||||
assert!(thread.source_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_channel_serde_backcompat() {
|
||||
// Simulate deserializing a Thread from older DB records that lack source_channel.
|
||||
let thread = Thread::new(Uuid::new_v4(), Some("cli"));
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
|
||||
// Remove the source_channel field to simulate an old record.
|
||||
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
value.as_object_mut().unwrap().remove("source_channel");
|
||||
let old_json = serde_json::to_string(&value).unwrap();
|
||||
|
||||
let deserialized: Thread = serde_json::from_str(&old_json).unwrap();
|
||||
assert!(
|
||||
deserialized.source_channel.is_none(),
|
||||
"missing source_channel should deserialize as None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_authorized_same_channel() {
|
||||
assert!(
|
||||
is_approval_authorized(Some("telegram"), "telegram"),
|
||||
"same channel should be authorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_authorized_different_channel_blocked() {
|
||||
assert!(
|
||||
!is_approval_authorized(Some("telegram"), "http"),
|
||||
"different channel should be blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_authorized_web_always_allowed() {
|
||||
assert!(
|
||||
is_approval_authorized(Some("telegram"), "web"),
|
||||
"web channel should always be authorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_authorized_gateway_always_allowed() {
|
||||
assert!(
|
||||
is_approval_authorized(Some("telegram"), "gateway"),
|
||||
"gateway channel should always be authorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_authorized_none_denied() {
|
||||
assert!(
|
||||
!is_approval_authorized(None, "telegram"),
|
||||
"None source_channel should be denied (fail-closed)"
|
||||
);
|
||||
assert!(
|
||||
!is_approval_authorized(None, "web"),
|
||||
"None source_channel should be denied even for web"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_authorized_bootstrap_any_channel() {
|
||||
assert!(
|
||||
is_approval_authorized(Some(BOOTSTRAP_SOURCE_CHANNEL), "telegram"),
|
||||
"__bootstrap__ should be authorized from any channel"
|
||||
);
|
||||
assert!(
|
||||
is_approval_authorized(Some(BOOTSTRAP_SOURCE_CHANNEL), "http"),
|
||||
"__bootstrap__ should be authorized from any channel"
|
||||
);
|
||||
assert!(
|
||||
is_approval_authorized(Some(BOOTSTRAP_SOURCE_CHANNEL), "cli"),
|
||||
"__bootstrap__ should be authorized from any channel"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ impl SessionManager {
|
||||
// Create new thread (always create a new one for a new key)
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread(Some(channel));
|
||||
thread.id
|
||||
};
|
||||
|
||||
@@ -476,7 +476,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-hydrate")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(thread_id, sess.id);
|
||||
let thread = Thread::with_id(thread_id, sess.id, None);
|
||||
sess.threads.insert(thread_id, thread);
|
||||
sess.active_thread = Some(thread_id);
|
||||
}
|
||||
@@ -600,7 +600,7 @@ mod tests {
|
||||
// Simulate hydration: create thread with a known UUID
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_uuid, session_id);
|
||||
let thread = Thread::with_id(known_uuid, session_id, None);
|
||||
sess.threads.insert(known_uuid, thread);
|
||||
}
|
||||
|
||||
@@ -627,7 +627,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-idem")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-undo")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -680,7 +680,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-new")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -788,7 +788,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -815,7 +815,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -966,7 +966,7 @@ mod tests {
|
||||
let adopted_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session1.lock().await;
|
||||
let thread = Thread::with_id(adopted_id, sess.id);
|
||||
let thread = Thread::with_id(adopted_id, sess.id, None);
|
||||
sess.threads.insert(adopted_id, thread);
|
||||
}
|
||||
// Resolve with the UUID as external_thread_id -- should adopt it
|
||||
@@ -992,7 +992,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-direct")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
{
|
||||
@@ -1030,7 +1030,7 @@ mod tests {
|
||||
let known_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_id, sess.id);
|
||||
let thread = Thread::with_id(known_id, sess.id, None);
|
||||
sess.threads.insert(known_id, thread);
|
||||
}
|
||||
|
||||
@@ -1057,7 +1057,7 @@ mod tests {
|
||||
let known_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_id, sess.id);
|
||||
let thread = Thread::with_id(known_id, sess.id, None);
|
||||
sess.threads.insert(known_id, thread);
|
||||
}
|
||||
|
||||
@@ -1081,7 +1081,7 @@ mod tests {
|
||||
let known_id = Uuid::new_v4();
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_id, sess.id);
|
||||
let thread = Thread::with_id(known_id, sess.id, None);
|
||||
sess.threads.insert(known_id, thread);
|
||||
}
|
||||
|
||||
@@ -1102,4 +1102,19 @@ mod tests {
|
||||
"should NOT adopt UUID when external_thread_id is None"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_thread_stores_source_channel() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
let (session, thread_id) = manager.resolve_thread("user-1", "telegram", None).await;
|
||||
|
||||
let sess = session.lock().await;
|
||||
let thread = sess.threads.get(&thread_id).unwrap();
|
||||
assert_eq!(
|
||||
thread.source_channel.as_deref(),
|
||||
Some("telegram"),
|
||||
"resolve_thread should store source_channel from the channel parameter"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-9
@@ -135,13 +135,29 @@ impl Agent {
|
||||
msg_count = 0;
|
||||
}
|
||||
|
||||
// Create thread with the historical ID and restore messages
|
||||
// Create thread with the historical ID and restore messages.
|
||||
// Read source_channel from DB so the authorization check uses the
|
||||
// original creator's channel, not the requesting message's channel.
|
||||
let db_source_channel = if let Some(store) = self.store() {
|
||||
store
|
||||
.get_conversation_source_channel(thread_uuid)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let effective_source_channel = db_source_channel.as_deref();
|
||||
|
||||
let session_id = {
|
||||
let sess = session.lock().await;
|
||||
sess.id
|
||||
};
|
||||
|
||||
let mut thread = crate::agent::session::Thread::with_id(thread_uuid, session_id);
|
||||
let mut thread = crate::agent::session::Thread::with_id(
|
||||
thread_uuid,
|
||||
session_id,
|
||||
effective_source_channel,
|
||||
);
|
||||
if !chat_messages.is_empty() {
|
||||
thread.restore_from_messages(chat_messages);
|
||||
}
|
||||
@@ -636,7 +652,7 @@ impl Agent {
|
||||
user_id: &str,
|
||||
) -> bool {
|
||||
match store
|
||||
.ensure_conversation(thread_id, channel, user_id, None)
|
||||
.ensure_conversation(thread_id, channel, user_id, None, Some(channel))
|
||||
.await
|
||||
{
|
||||
Ok(true) => true,
|
||||
@@ -1781,7 +1797,7 @@ impl Agent {
|
||||
.get_or_create_session(&message.user_id)
|
||||
.await;
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread(Some(&message.channel));
|
||||
let thread_id = thread.id;
|
||||
Ok(SubmissionResult::ok_with_message(format!(
|
||||
"New thread: {}",
|
||||
@@ -2117,7 +2133,7 @@ mod tests {
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
|
||||
// Set thread to AwaitingApproval with a pending tool approval
|
||||
let pending = PendingApproval {
|
||||
@@ -2185,7 +2201,7 @@ mod tests {
|
||||
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("processing something");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
@@ -2211,7 +2227,7 @@ mod tests {
|
||||
use crate::agent::session::{Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("processing");
|
||||
|
||||
thread.queue_message("pending-1".to_string());
|
||||
@@ -2241,7 +2257,7 @@ mod tests {
|
||||
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
thread.start_turn("working");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
@@ -2268,7 +2284,7 @@ mod tests {
|
||||
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
thread.start_turn("working");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
|
||||
+2
-7
@@ -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(
|
||||
|
||||
@@ -34,6 +34,7 @@ pub async fn setup_wasm_channels(
|
||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
extension_manager: Option<&Arc<ExtensionManager>>,
|
||||
database: Option<&Arc<dyn Database>>,
|
||||
registered_channel_names: &[String],
|
||||
) -> Option<WasmChannelSetup> {
|
||||
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
|
||||
Ok(r) => Arc::new(r),
|
||||
@@ -71,7 +72,46 @@ pub async fn setup_wasm_channels(
|
||||
let mut channels: Vec<(String, Box<dyn crate::channels::Channel>)> = Vec::new();
|
||||
let mut channel_names: Vec<String> = Vec::new();
|
||||
|
||||
// Reserved channel names that WASM modules must not claim.
|
||||
// A malicious module could otherwise register as a trusted built-in
|
||||
// channel and bypass cross-channel authorization checks.
|
||||
// This list must cover every built-in channel name to prevent a WASM
|
||||
// module from impersonating a built-in and satisfying same-channel
|
||||
// approval checks.
|
||||
const RESERVED_CHANNEL_NAMES: &[&str] = &[
|
||||
"web",
|
||||
"gateway",
|
||||
"cli",
|
||||
"repl",
|
||||
"http",
|
||||
"signal",
|
||||
"slack-relay",
|
||||
"secret_save",
|
||||
];
|
||||
|
||||
for loaded in results.loaded {
|
||||
let name_lower = loaded.name().to_ascii_lowercase();
|
||||
if RESERVED_CHANNEL_NAMES.contains(&name_lower.as_str()) {
|
||||
tracing::warn!(
|
||||
channel = %loaded.name(),
|
||||
"Rejected WASM channel with reserved name"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Also reject any name that collides with an already-registered
|
||||
// channel to prevent a WASM module from shadowing a channel that
|
||||
// was registered earlier in the startup sequence.
|
||||
if registered_channel_names
|
||||
.iter()
|
||||
.any(|n| n.to_ascii_lowercase() == name_lower)
|
||||
{
|
||||
tracing::warn!(
|
||||
channel = %loaded.name(),
|
||||
"Rejected WASM channel that collides with already-registered channel"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name, channel) = register_channel(
|
||||
loaded,
|
||||
config,
|
||||
|
||||
@@ -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 |
|
||||
|--------|------|-------------|
|
||||
|
||||
+220
-20
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, UserIdentity>) -> 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<dyn Database>,
|
||||
/// Bounded LRU cache: token_hash → (identity, inserted_at).
|
||||
cache: Arc<RwLock<lru::LruCache<[u8; 32], (UserIdentity, Instant)>>>,
|
||||
}
|
||||
|
||||
impl DbAuthenticator {
|
||||
/// Cache TTL — how long a successful auth is cached before re-querying the DB.
|
||||
const CACHE_TTL_SECS: u64 = 60;
|
||||
/// Maximum cache entries to prevent unbounded growth.
|
||||
// SAFETY: 1024 is non-zero, so the unwrap in `new()` is infallible.
|
||||
const MAX_CACHE_ENTRIES: NonZeroUsize = match NonZeroUsize::new(1024) {
|
||||
Some(v) => v,
|
||||
None => unreachable!(),
|
||||
};
|
||||
|
||||
pub fn new(store: Arc<dyn Database>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
cache: Arc::new(RwLock::new(lru::LruCache::new(Self::MAX_CACHE_ENTRIES))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict all cached entries for a specific user.
|
||||
///
|
||||
/// Call this after security-critical actions (suspend, activate, role
|
||||
/// change, token revocation) so the change takes effect immediately
|
||||
/// instead of waiting for the 60-second TTL to expire.
|
||||
pub async fn invalidate_user(&self, user_id: &str) {
|
||||
let mut cache = self.cache.write().await;
|
||||
// LruCache doesn't support predicate-based removal, so collect keys
|
||||
// first then remove. The cache is bounded (1024) so this is cheap.
|
||||
let keys_to_remove: Vec<[u8; 32]> = cache
|
||||
.iter()
|
||||
.filter(|(_, (identity, _))| identity.user_id == user_id)
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
for key in keys_to_remove {
|
||||
cache.pop(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate a token against the database, using cache when possible.
|
||||
///
|
||||
/// Returns `Ok(Some(identity))` on success, `Ok(None)` if the token is
|
||||
/// not found, or `Err(())` if the database is unreachable (so the caller
|
||||
/// can return 503 instead of 401).
|
||||
pub async fn authenticate(&self, candidate: &str) -> Result<Option<UserIdentity>, ()> {
|
||||
let hash = hash_token(candidate);
|
||||
|
||||
// Check cache first (promotes to most-recent on hit)
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
if let Some((identity, inserted_at)) = cache.get(&hash) {
|
||||
if inserted_at.elapsed().as_secs() < Self::CACHE_TTL_SECS {
|
||||
return Ok(Some(identity.clone()));
|
||||
}
|
||||
// Expired — remove stale entry
|
||||
cache.pop(&hash);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired — query DB
|
||||
let (token_record, user_record) = match self.store.authenticate_token(&hash).await {
|
||||
Ok(Some(pair)) => pair,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(e) => {
|
||||
tracing::warn!("DB auth lookup failed: {e}");
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
|
||||
let identity = UserIdentity {
|
||||
user_id: user_record.id.clone(),
|
||||
role: user_record.role.clone(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
};
|
||||
|
||||
// Record token usage (best-effort, don't block auth)
|
||||
let store = self.store.clone();
|
||||
let token_id = token_record.id;
|
||||
let user_id = user_record.id;
|
||||
tokio::spawn(async move {
|
||||
let _ = store.record_token_usage(token_id).await;
|
||||
let _ = store.record_login(&user_id).await;
|
||||
});
|
||||
|
||||
// Insert into bounded LRU — if full, least-recently-used entry is evicted
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.put(hash, (identity.clone(), Instant::now()));
|
||||
}
|
||||
|
||||
Ok(Some(identity))
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
|
||||
#[derive(Clone)]
|
||||
pub struct CombinedAuthState {
|
||||
/// In-memory tokens from GATEWAY_AUTH_TOKEN.
|
||||
pub env_auth: MultiAuthState,
|
||||
/// DB-backed token authenticator (optional — only when a database is available).
|
||||
pub db_auth: Option<DbAuthenticator>,
|
||||
}
|
||||
|
||||
impl From<MultiAuthState> for CombinedAuthState {
|
||||
fn from(env_auth: MultiAuthState) -> Self {
|
||||
Self {
|
||||
env_auth,
|
||||
db_auth: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum extractor that provides the authenticated user identity.
|
||||
///
|
||||
/// Only available on routes behind `auth_middleware`. Extracts the
|
||||
@@ -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<S> FromRequestParts<S> for AdminUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let identity = parts
|
||||
.extensions
|
||||
.get::<UserIdentity>()
|
||||
.cloned()
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
|
||||
if identity.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Admin role required"));
|
||||
}
|
||||
Ok(AdminUser(identity))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether query-string token auth is allowed for this request.
|
||||
///
|
||||
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
|
||||
@@ -166,39 +330,65 @@ fn query_token(request: &Request) -> Option<String> {
|
||||
|
||||
/// 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<MultiAuthState>,
|
||||
State(auth): State<CombinedAuthState>,
|
||||
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<String> {
|
||||
// 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<String, UserIdentity>) -> 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));
|
||||
|
||||
@@ -574,7 +574,7 @@ pub async fn chat_new_thread_handler(
|
||||
.await;
|
||||
let (thread_id, info) = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread(Some("web"));
|
||||
let id = thread.id;
|
||||
let info = ThreadInfo {
|
||||
id: thread.id,
|
||||
@@ -593,7 +593,13 @@ pub async fn chat_new_thread_handler(
|
||||
// so that the subsequent loadThreads() call from the frontend sees it.
|
||||
if let Some(ref store) = state.store {
|
||||
match store
|
||||
.ensure_conversation(thread_id, "gateway", &identity.user_id, None)
|
||||
.ensure_conversation(
|
||||
thread_id,
|
||||
"gateway",
|
||||
&identity.user_id,
|
||||
None,
|
||||
Some("gateway"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
|
||||
@@ -15,6 +15,14 @@ use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
fn db_error(context: &str, e: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
tracing::error!(%e, context, "Database error in jobs handler");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal database error".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn jobs_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
@@ -213,10 +221,7 @@ pub async fn jobs_detail_handler(
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
));
|
||||
return Err(db_error("jobs_handler", e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +262,7 @@ pub async fn jobs_detail_handler(
|
||||
}))
|
||||
}
|
||||
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
)),
|
||||
Err(e) => Err(db_error("jobs_handler", e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,10 +306,7 @@ pub async fn jobs_cancel_handler(
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
));
|
||||
return Err(db_error("jobs_handler", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,10 +349,7 @@ pub async fn jobs_cancel_handler(
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
));
|
||||
return Err(db_error("jobs_handler", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,10 +467,7 @@ pub async fn jobs_restart_handler(
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
));
|
||||
return Err(db_error("jobs_handler", e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,10 +523,7 @@ pub async fn jobs_restart_handler(
|
||||
})))
|
||||
}
|
||||
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
)),
|
||||
Err(e) => Err(db_error("jobs_handler", e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,10 +599,7 @@ pub async fn jobs_prompt_handler(
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
));
|
||||
return Err(db_error("jobs_handler", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -667,10 +654,7 @@ pub async fn jobs_events_handler(
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {}", e),
|
||||
));
|
||||
return Err(db_error("jobs_handler", e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,3 +807,17 @@ pub async fn job_files_read_handler(
|
||||
content,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_db_error_does_not_leak_details() {
|
||||
let (status, body) = db_error("test_context", "relation \"jobs\" does not exist");
|
||||
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(body, "Internal database error");
|
||||
assert!(!body.contains("relation"));
|
||||
assert!(!body.contains("does not exist"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Arc<GatewayState>>,
|
||||
AdminUser(_admin): AdminUser,
|
||||
Path((user_id, name)): Path<(String, String)>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let name = name.to_lowercase();
|
||||
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
store
|
||||
.get_user(&user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
let secrets = state.secrets_store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Secrets store not available".to_string(),
|
||||
))?;
|
||||
|
||||
let value = body
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing required field 'value'".to_string(),
|
||||
))?
|
||||
.to_string();
|
||||
|
||||
let provider = body
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let expires_in_days = body.get("expires_in_days").and_then(|v| v.as_u64());
|
||||
if let Some(days) = expires_in_days
|
||||
&& days > 36500
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"expires_in_days must be at most 36500".to_string(),
|
||||
));
|
||||
}
|
||||
let expires_at =
|
||||
expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days as i64));
|
||||
|
||||
let mut params = CreateSecretParams::new(name.clone(), value);
|
||||
if let Some(p) = provider {
|
||||
params = params.with_provider(p);
|
||||
}
|
||||
if let Some(exp) = expires_at {
|
||||
params = params.with_expiry(exp);
|
||||
}
|
||||
|
||||
let already_exists = secrets
|
||||
.exists(&user_id, &name)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
secrets
|
||||
.create(&user_id, params)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"status": if already_exists { "updated" } else { "created" },
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users/{user_id}/secrets — list a user's secrets (names only).
|
||||
///
|
||||
/// Never returns secret values or hashes.
|
||||
pub async fn secrets_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_admin): AdminUser,
|
||||
Path(user_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Verify the target user exists (consistent with PUT/DELETE).
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
if store
|
||||
.get_user(&user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
|
||||
}
|
||||
|
||||
let secrets = state.secrets_store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Secrets store not available".to_string(),
|
||||
))?;
|
||||
|
||||
let refs = secrets
|
||||
.list(&user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let secrets_json: Vec<serde_json::Value> = refs
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"name": r.name,
|
||||
"provider": r.provider,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"secrets": secrets_json,
|
||||
})))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{user_id}/secrets/{name} — delete a user's secret.
|
||||
pub async fn secrets_delete_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_admin): AdminUser,
|
||||
Path((user_id, name)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let name = name.to_lowercase();
|
||||
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
store
|
||||
.get_user(&user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
let secrets = state.secrets_store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Secrets store not available".to_string(),
|
||||
))?;
|
||||
|
||||
let deleted = secrets
|
||||
.delete(&user_id, &name)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !deleted {
|
||||
return Err((StatusCode::NOT_FOUND, "Secret not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"deleted": true,
|
||||
})))
|
||||
}
|
||||
@@ -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<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let name = body
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing or empty 'name'".to_string(),
|
||||
))?
|
||||
.to_string();
|
||||
|
||||
let expires_in_days: Option<i64> = match body.get("expires_in_days").and_then(|v| v.as_u64()) {
|
||||
Some(d) if d > 36500 => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"expires_in_days must not exceed 36500 (100 years)".to_string(),
|
||||
));
|
||||
}
|
||||
Some(d) => Some(d as i64),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let expires_at = expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days));
|
||||
|
||||
// Generate 32 random bytes for the token.
|
||||
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
|
||||
// NOT the raw bytes — must match hash_token() in auth.rs.
|
||||
let mut token_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut token_bytes);
|
||||
let plaintext_token = hex::encode(token_bytes);
|
||||
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
|
||||
|
||||
// First 8 chars of the hex token as a prefix for identification.
|
||||
let token_prefix = &plaintext_token[..8];
|
||||
|
||||
// Admin users can create tokens for other users via optional "user_id" field.
|
||||
let target_user = body
|
||||
.get("user_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|_| user.role == "admin")
|
||||
.unwrap_or(&user.user_id);
|
||||
|
||||
// Verify the target user exists to prevent orphan tokens.
|
||||
if target_user != user.user_id {
|
||||
store
|
||||
.get_user(target_user)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Target user '{target_user}' not found"),
|
||||
))?;
|
||||
}
|
||||
|
||||
let record = store
|
||||
.create_api_token(target_user, &name, &hash, token_prefix, expires_at)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Return the plaintext token — this is the ONLY time it is shown.
|
||||
Ok(Json(serde_json::json!({
|
||||
"token": plaintext_token,
|
||||
"id": record.id.to_string(),
|
||||
"name": record.name,
|
||||
"token_prefix": record.token_prefix,
|
||||
"expires_at": record.expires_at.map(|dt| dt.to_rfc3339()),
|
||||
"created_at": record.created_at.to_rfc3339(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/tokens — list the current user's tokens (no hashes).
|
||||
pub async fn tokens_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let tokens = store
|
||||
.list_api_tokens(&user.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let tokens_json: Vec<serde_json::Value> = tokens
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
serde_json::json!({
|
||||
"id": t.id.to_string(),
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"expires_at": t.expires_at.map(|dt| dt.to_rfc3339()),
|
||||
"last_used_at": t.last_used_at.map(|dt| dt.to_rfc3339()),
|
||||
"created_at": t.created_at.to_rfc3339(),
|
||||
"revoked_at": t.revoked_at.map(|dt| dt.to_rfc3339()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "tokens": tokens_json })))
|
||||
}
|
||||
|
||||
/// DELETE /api/tokens/{id} — revoke a token.
|
||||
pub async fn tokens_revoke_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let token_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid token ID".to_string()))?;
|
||||
|
||||
let revoked = store
|
||||
.revoke_api_token(token_id, &user.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !revoked {
|
||||
return Err((StatusCode::NOT_FOUND, "Token not found".to_string()));
|
||||
}
|
||||
|
||||
// Evict cached auth so revocation takes effect immediately.
|
||||
if let Some(ref db_auth) = state.db_auth {
|
||||
db_auth.invalidate_user(&user.user_id).await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "revoked",
|
||||
"id": token_id.to_string(),
|
||||
})))
|
||||
}
|
||||
@@ -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<bool, String> {
|
||||
let users = store
|
||||
.list_users(Some("active"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let active_admins: Vec<_> = users.iter().filter(|u| u.role == "admin").collect();
|
||||
Ok(active_admins.len() == 1 && active_admins[0].id == user_id)
|
||||
}
|
||||
|
||||
/// POST /api/admin/users — create a new user.
|
||||
pub async fn users_create_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(user): AdminUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let display_name = body
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing or empty 'display_name'".to_string(),
|
||||
))?
|
||||
.to_string();
|
||||
|
||||
let email = body
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from);
|
||||
let role = body
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("member")
|
||||
.to_string();
|
||||
if role != "admin" && role != "member" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"role must be 'admin' or 'member'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4().to_string();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let user_record = UserRecord {
|
||||
id: user_id.clone(),
|
||||
email,
|
||||
display_name: display_name.clone(),
|
||||
status: "active".to_string(),
|
||||
role,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
created_by: Some(user.user_id.clone()),
|
||||
metadata: serde_json::json!({}),
|
||||
};
|
||||
|
||||
// Generate a first API token so the new user can authenticate immediately.
|
||||
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
|
||||
// NOT the raw bytes — must match hash_token() in auth.rs.
|
||||
let mut token_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut token_bytes);
|
||||
let plaintext_token = hex::encode(token_bytes);
|
||||
let token_hash = crate::channels::web::auth::hash_token(&plaintext_token);
|
||||
let token_prefix = &plaintext_token[..8];
|
||||
|
||||
// Create user and initial token atomically — if either fails, both roll back.
|
||||
let _token_record = store
|
||||
.create_user_with_token(&user_record, "initial", &token_hash, token_prefix, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
let lower = msg.to_ascii_lowercase();
|
||||
if lower.contains("unique")
|
||||
|| lower.contains("duplicate")
|
||||
|| lower.contains("already exists")
|
||||
{
|
||||
(StatusCode::CONFLICT, msg)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": user_record.id,
|
||||
"email": user_record.email,
|
||||
"display_name": user_record.display_name,
|
||||
"status": user_record.status,
|
||||
"role": user_record.role,
|
||||
"token": plaintext_token,
|
||||
"created_at": user_record.created_at.to_rfc3339(),
|
||||
"created_by": user_record.created_by,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users — list all users with inline usage stats.
|
||||
pub async fn users_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let users = store
|
||||
.list_users(None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Fetch per-user summary stats from DB (agent_jobs + llm_calls).
|
||||
let summary_stats = store
|
||||
.user_summary_stats(None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let stats_map: std::collections::HashMap<String, _> = summary_stats
|
||||
.into_iter()
|
||||
.map(|s| (s.user_id.clone(), s))
|
||||
.collect();
|
||||
|
||||
let mut users_json: Vec<serde_json::Value> = Vec::with_capacity(users.len());
|
||||
for u in users {
|
||||
let db_stats = stats_map.get(&u.id);
|
||||
let total_cost = db_stats.map_or(rust_decimal::Decimal::ZERO, |s| s.total_cost);
|
||||
|
||||
// Last active: prefer DB timestamp, fall back to last_login_at.
|
||||
let last_active = db_stats.and_then(|s| s.last_active_at).or(u.last_login_at);
|
||||
|
||||
users_json.push(serde_json::json!({
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"status": u.status,
|
||||
"role": u.role,
|
||||
"created_at": u.created_at.to_rfc3339(),
|
||||
"updated_at": u.updated_at.to_rfc3339(),
|
||||
"last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()),
|
||||
"created_by": u.created_by,
|
||||
"job_count": db_stats.map_or(0, |s| s.job_count),
|
||||
"total_cost": total_cost.to_string(),
|
||||
"last_active_at": last_active.map(|dt| dt.to_rfc3339()),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "users": users_json })))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users/{id} — get a single user.
|
||||
pub async fn users_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let user_record = store
|
||||
.get_user(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": user_record.id,
|
||||
"email": user_record.email,
|
||||
"display_name": user_record.display_name,
|
||||
"status": user_record.status,
|
||||
"role": user_record.role,
|
||||
"created_at": user_record.created_at.to_rfc3339(),
|
||||
"updated_at": user_record.updated_at.to_rfc3339(),
|
||||
"last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()),
|
||||
"created_by": user_record.created_by,
|
||||
"metadata": user_record.metadata,
|
||||
})))
|
||||
}
|
||||
|
||||
/// PATCH /api/admin/users/{id} — update a user's profile.
|
||||
pub async fn users_update_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Verify the user exists.
|
||||
let existing = store
|
||||
.get_user(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
let display_name = body
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&existing.display_name);
|
||||
|
||||
let metadata = if let Some(m) = body.get("metadata") {
|
||||
if !m.is_object() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"metadata must be a JSON object".to_string(),
|
||||
));
|
||||
}
|
||||
m
|
||||
} else {
|
||||
&existing.metadata
|
||||
};
|
||||
|
||||
// Update role if provided and valid.
|
||||
if let Some(role) = body.get("role").and_then(|v| v.as_str()) {
|
||||
if role != "admin" && role != "member" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"role must be 'admin' or 'member'".to_string(),
|
||||
));
|
||||
}
|
||||
if role != existing.role {
|
||||
// Prevent demoting the last admin.
|
||||
if existing.role == "admin"
|
||||
&& role == "member"
|
||||
&& is_last_admin(store.as_ref(), &id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
|
||||
{
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
"Cannot demote the last admin".to_string(),
|
||||
));
|
||||
}
|
||||
store
|
||||
.update_user_role(&id, role)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
// Evict cached auth so role change takes effect immediately.
|
||||
if let Some(ref db_auth) = state.db_auth {
|
||||
db_auth.invalidate_user(&id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
.update_user_profile(&id, display_name, metadata)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Re-fetch the updated record to return consistent data.
|
||||
let updated = store
|
||||
.get_user(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": updated.id,
|
||||
"email": updated.email,
|
||||
"display_name": updated.display_name,
|
||||
"status": updated.status,
|
||||
"role": updated.role,
|
||||
"created_at": updated.created_at.to_rfc3339(),
|
||||
"updated_at": updated.updated_at.to_rfc3339(),
|
||||
"metadata": updated.metadata,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/suspend — suspend a user.
|
||||
pub async fn users_suspend_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Verify the user exists.
|
||||
store
|
||||
.get_user(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
// Prevent suspending the last admin.
|
||||
if is_last_admin(store.as_ref(), &id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
|
||||
{
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
"Cannot suspend the last admin".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
store
|
||||
.update_user_status(&id, "suspended")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Evict cached auth so suspension takes effect immediately.
|
||||
if let Some(ref db_auth) = state.db_auth {
|
||||
db_auth.invalidate_user(&id).await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"status": "suspended",
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/activate — activate a user.
|
||||
pub async fn users_activate_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Verify the user exists.
|
||||
store
|
||||
.get_user(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
store
|
||||
.update_user_status(&id, "active")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Evict cached auth so reactivation takes effect immediately.
|
||||
if let Some(ref db_auth) = state.db_auth {
|
||||
db_auth.invalidate_user(&id).await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"status": "active",
|
||||
})))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{id} — delete a user and all their data.
|
||||
pub async fn users_delete_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Prevent deleting the last admin.
|
||||
if is_last_admin(store.as_ref(), &id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
|
||||
{
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
"Cannot delete the last admin".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let deleted = store
|
||||
.delete_user(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !deleted {
|
||||
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": id,
|
||||
"deleted": true,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/profile — get the authenticated user's own profile.
|
||||
pub async fn profile_get_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let record = store
|
||||
.get_user(&user.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": record.id,
|
||||
"email": record.email,
|
||||
"display_name": record.display_name,
|
||||
"status": record.status,
|
||||
"role": record.role,
|
||||
"created_at": record.created_at.to_rfc3339(),
|
||||
"last_login_at": record.last_login_at.map(|dt| dt.to_rfc3339()),
|
||||
})))
|
||||
}
|
||||
|
||||
/// PATCH /api/profile — update the authenticated user's own profile.
|
||||
pub async fn profile_update_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let current = store
|
||||
.get_user(&user.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
let display_name = body
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(¤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<Arc<GatewayState>>,
|
||||
AdminUser(_user): AdminUser,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let user_id = params.get("user_id").map(|s| s.as_str());
|
||||
let period = params.get("period").map(|s| s.as_str()).unwrap_or("day");
|
||||
let since = match period {
|
||||
"week" => chrono::Utc::now() - chrono::Duration::days(7),
|
||||
"month" => chrono::Utc::now() - chrono::Duration::days(30),
|
||||
_ => chrono::Utc::now() - chrono::Duration::days(1),
|
||||
};
|
||||
|
||||
let stats = store
|
||||
.user_usage_stats(user_id, since)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = stats
|
||||
.iter()
|
||||
.map(|s| {
|
||||
serde_json::json!({
|
||||
"user_id": s.user_id,
|
||||
"model": s.model,
|
||||
"call_count": s.call_count,
|
||||
"input_tokens": s.input_tokens,
|
||||
"output_tokens": s.output_tokens,
|
||||
"total_cost": s.total_cost.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"period": period,
|
||||
"since": since.to_rfc3339(),
|
||||
"usage": entries,
|
||||
})))
|
||||
}
|
||||
@@ -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<Arc<GatewayState>>,
|
||||
Path(path): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// In multi-tenant mode, reject unscoped webhooks to prevent cross-user
|
||||
// routine triggering. The per-routine secret provides some protection,
|
||||
// but tenant isolation requires scoping by user_id.
|
||||
// Use workspace_pool as the multi-tenant indicator — it's only set when
|
||||
// has_any_users() was true at startup (not just when a DB exists).
|
||||
if state.workspace_pool.is_some() {
|
||||
return Err((
|
||||
StatusCode::GONE,
|
||||
"Unscoped webhooks disabled in multi-tenant mode. Use /api/webhooks/u/{user_id}/{path} instead.".to_string(),
|
||||
));
|
||||
}
|
||||
fire_webhook_inner(state, &path, None, &headers).await
|
||||
}
|
||||
|
||||
|
||||
+34
-65
@@ -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<GatewayState>,
|
||||
/// 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<String>) -> Self {
|
||||
let owner_id = owner_id.into();
|
||||
let single_user_token = if self.config.user_tokens.is_none() {
|
||||
self.auth.first_token().map(ToOwned::to_owned)
|
||||
} else {
|
||||
None
|
||||
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<dyn Database>) -> Self {
|
||||
let authenticator = DbAuthenticator::new(store);
|
||||
// Share the same DbAuthenticator (and its cache) between the auth
|
||||
// middleware and GatewayState so handlers can invalidate the cache
|
||||
// on security-critical actions (suspend, role change, token revoke).
|
||||
self.rebuild_state(|s| s.db_auth = Some(Arc::new(authenticator.clone())));
|
||||
self.auth.db_auth = Some(authenticator);
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the container job manager for sandbox operations.
|
||||
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
||||
self.rebuild_state(|s| s.job_manager = Some(jm));
|
||||
@@ -328,6 +288,15 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the secrets store for admin secret provisioning.
|
||||
pub fn with_secrets_store(
|
||||
mut self,
|
||||
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
) -> Self {
|
||||
self.rebuild_state(|s| s.secrets_store = Some(store));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the per-user workspace pool for multi-user mode.
|
||||
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
||||
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
||||
@@ -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).
|
||||
|
||||
+111
-15
@@ -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<PromptQueue>,
|
||||
/// Durable owner scope for persistence and unauthenticated callback flows.
|
||||
pub owner_id: String,
|
||||
/// Default sender/routing identity for gateway-originated messages.
|
||||
pub default_sender_id: String,
|
||||
/// Shutdown signal sender.
|
||||
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
|
||||
/// WebSocket connection tracker.
|
||||
@@ -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<Arc<dyn crate::secrets::SecretsStore + Send + Sync>>,
|
||||
/// DB auth cache for invalidation on security-critical actions.
|
||||
pub db_auth: Option<Arc<crate::channels::web::auth::DbAuthenticator>>,
|
||||
}
|
||||
|
||||
/// Start the gateway HTTP server.
|
||||
@@ -386,7 +403,7 @@ pub struct GatewayState {
|
||||
pub async fn start_server(
|
||||
addr: SocketAddr,
|
||||
state: Arc<GatewayState>,
|
||||
auth: MultiAuthState,
|
||||
auth: CombinedAuthState,
|
||||
) -> Result<SocketAddr, crate::error::ChannelError> {
|
||||
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<dyn std::any::Any + Send + 'static>| {
|
||||
let detail = if let Some(s) = panic_info.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else if let Some(s) = panic_info.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
};
|
||||
// Truncate panic payload to avoid leaking sensitive data into logs.
|
||||
// Use floor_char_boundary to avoid panicking on multi-byte UTF-8.
|
||||
let safe_detail = if detail.len() > 200 {
|
||||
let end = detail.floor_char_boundary(200);
|
||||
format!("{}…", &detail[..end])
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
tracing::error!("Handler panicked: {}", safe_detail);
|
||||
axum::http::Response::builder()
|
||||
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("content-type", "text/plain")
|
||||
.body(axum::body::Body::from("Internal Server Error"))
|
||||
.unwrap_or_else(|_| {
|
||||
axum::http::Response::new(axum::body::Body::from("Internal Server Error"))
|
||||
})
|
||||
},
|
||||
))
|
||||
.layer(cors)
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
@@ -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);
|
||||
@@ -1924,7 +2014,7 @@ async fn chat_new_thread_handler(
|
||||
let session = session_manager.get_or_create_session(&user.user_id).await;
|
||||
let (thread_id, info) = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread(Some("gateway"));
|
||||
let id = thread.id;
|
||||
let info = ThreadInfo {
|
||||
id: thread.id,
|
||||
@@ -1943,7 +2033,7 @@ async fn chat_new_thread_handler(
|
||||
// so that the subsequent loadThreads() call from the frontend sees it.
|
||||
if let Some(ref store) = state.store {
|
||||
match store
|
||||
.ensure_conversation(thread_id, "gateway", &user.user_id, None)
|
||||
.ensure_conversation(thread_id, "gateway", &user.user_id, None, Some("gateway"))
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
@@ -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");
|
||||
|
||||
@@ -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' ? '<span class="badge badge-admin">' + I18n.t('users.roleAdmin') + '</span>' : '<span class="badge">' + I18n.t('users.roleMember') + '</span>';
|
||||
var actions = '';
|
||||
if (u.status === 'active') {
|
||||
actions += '<button class="btn-small btn-danger" data-action="suspend-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.suspend') + '</button> ';
|
||||
} else {
|
||||
actions += '<button class="btn-small btn-primary" data-action="activate-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.activate') + '</button> ';
|
||||
}
|
||||
if (u.role === 'member') {
|
||||
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="admin">' + I18n.t('users.makeAdmin') + '</button> ';
|
||||
} else {
|
||||
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="member">' + I18n.t('users.makeMember') + '</button> ';
|
||||
}
|
||||
actions += '<button class="btn-small" data-action="create-token" data-user-id="' + escapeHtml(u.id) + '" data-user-name="' + escapeHtml(u.display_name) + '">' + I18n.t('users.addToken') + '</button>';
|
||||
return '<tr>'
|
||||
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
|
||||
+ '<td>' + escapeHtml(u.display_name) + '</td>'
|
||||
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
|
||||
+ '<td>' + roleLabel + '</td>'
|
||||
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
|
||||
+ '<td>' + (u.job_count || 0) + '</td>'
|
||||
+ '<td>' + formatCost(u.total_cost) + '</td>'
|
||||
+ '<td>' + (u.last_active_at ? formatRelativeTime(u.last_active_at) : '—') + '</td>'
|
||||
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
|
||||
+ '<td>' + actions + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function suspendUser(userId) {
|
||||
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
|
||||
.then(function() { loadUsers(); })
|
||||
.catch(function(e) { alert(I18n.t('users.failedSuspend') + ': ' + e.message); });
|
||||
}
|
||||
|
||||
function activateUser(userId) {
|
||||
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
|
||||
.then(function() { loadUsers(); })
|
||||
.catch(function(e) { alert(I18n.t('users.failedActivate') + ': ' + e.message); });
|
||||
}
|
||||
|
||||
function changeUserRole(userId, newRole) {
|
||||
apiFetch('/api/admin/users/' + userId, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRole })
|
||||
})
|
||||
.then(function() { loadUsers(); })
|
||||
.catch(function(e) { alert(I18n.t('users.failedRoleChange') + ': ' + e.message); });
|
||||
}
|
||||
|
||||
function createTokenForUser(userId, displayName) {
|
||||
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
|
||||
if (!tokenName) return;
|
||||
apiFetch('/api/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: tokenName, user_id: userId }),
|
||||
}).then(function(data) {
|
||||
showTokenBanner(data.token, I18n.t('users.tokenCreated'));
|
||||
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
|
||||
}
|
||||
|
||||
function showTokenBanner(tokenValue, title) {
|
||||
var banner = document.getElementById('users-token-result');
|
||||
if (!banner) return;
|
||||
var heading = title || I18n.t('users.tokenCreated');
|
||||
var loginUrl = window.location.origin + '/?token=' + encodeURIComponent(tokenValue);
|
||||
banner.style.display = 'block';
|
||||
banner.innerHTML = '<strong>' + escapeHtml(heading) + '</strong> ' + I18n.t('users.tokenShareMessage') + '<br>'
|
||||
+ '<code class="token-display" id="token-copy-value">' + escapeHtml(loginUrl) + '</code>'
|
||||
+ '<button class="btn-small" id="token-copy-link">Copy Link</button>'
|
||||
+ '<br><span style="font-size:0.8em;color:var(--text-muted)">' + I18n.t('users.rawToken') + ' ' + escapeHtml(tokenValue) + '</span>';
|
||||
document.getElementById('token-copy-link').addEventListener('click', function() {
|
||||
navigator.clipboard.writeText(loginUrl);
|
||||
this.textContent = I18n.t('users.copied');
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated click handler for user action buttons (CSP-safe, no inline onclick)
|
||||
document.getElementById('users-table')?.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
var action = btn.getAttribute('data-action');
|
||||
var userId = btn.getAttribute('data-user-id');
|
||||
var userName = btn.getAttribute('data-user-name');
|
||||
if (action === 'suspend-user') suspendUser(userId);
|
||||
else if (action === 'activate-user') activateUser(userId);
|
||||
else if (action === 'change-role') changeUserRole(userId, btn.getAttribute('data-role'));
|
||||
else if (action === 'create-token') createTokenForUser(userId, userName || '');
|
||||
});
|
||||
|
||||
// Wire up Users tab create form
|
||||
document.getElementById('users-create-btn')?.addEventListener('click', function() {
|
||||
document.getElementById('users-create-form').style.display = 'flex';
|
||||
document.getElementById('users-token-result').style.display = 'none';
|
||||
document.getElementById('user-display-name').focus();
|
||||
});
|
||||
|
||||
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
|
||||
document.getElementById('users-create-form').style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('users-create-submit')?.addEventListener('click', function() {
|
||||
var displayName = document.getElementById('user-display-name').value.trim();
|
||||
var email = document.getElementById('user-email').value.trim();
|
||||
var role = document.getElementById('user-role').value;
|
||||
if (!displayName) { alert(I18n.t('users.displayNameRequired')); return; }
|
||||
|
||||
apiFetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
display_name: displayName,
|
||||
email: email || undefined,
|
||||
role: role,
|
||||
}),
|
||||
}).then(function(data) {
|
||||
document.getElementById('users-create-form').style.display = 'none';
|
||||
document.getElementById('user-display-name').value = '';
|
||||
document.getElementById('user-email').value = '';
|
||||
if (data.token) {
|
||||
showTokenBanner(data.token, I18n.t('users.userCreated'));
|
||||
}
|
||||
loadUsers();
|
||||
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
|
||||
});
|
||||
|
||||
// --- Gateway status widget ---
|
||||
|
||||
let gatewayStatusInterval = null;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '已断开',
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
|
||||
</div>
|
||||
<div id="auth-error"></div>
|
||||
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,6 +292,7 @@
|
||||
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
||||
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
||||
<button class="settings-subtab" data-settings-subtab="users" data-i18n="settings.users">Users</button>
|
||||
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
@@ -390,6 +390,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-users">
|
||||
<div class="users-container">
|
||||
<div class="users-header">
|
||||
<h3 data-i18n="users.heading">User Management</h3>
|
||||
<button id="users-create-btn" class="btn-primary" data-i18n="users.newUser">+ New User</button>
|
||||
</div>
|
||||
<div id="users-create-form" style="display:none" class="users-form" autocomplete="off">
|
||||
<input type="text" id="user-display-name" data-i18n-placeholder="users.displayNamePlaceholder" placeholder="Display name" autocomplete="off" />
|
||||
<input type="text" id="user-email" data-i18n-placeholder="users.emailPlaceholder" placeholder="Email (optional)" autocomplete="off" />
|
||||
<select id="user-role"><option value="member" data-i18n="users.roleMember">Member</option><option value="admin" data-i18n="users.roleAdmin">Admin</option></select>
|
||||
<button id="users-create-submit" class="btn-primary" data-i18n="users.create">Create</button>
|
||||
<button id="users-create-cancel" class="btn-secondary" data-i18n="users.cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="users-token-result" style="display:none" class="users-token-banner"></div>
|
||||
<table class="routines-table" id="users-table">
|
||||
<thead><tr>
|
||||
<th data-i18n="users.columns.id">ID</th><th data-i18n="users.columns.displayName">Display Name</th><th data-i18n="users.columns.email">Email</th><th data-i18n="users.columns.role">Role</th><th data-i18n="users.columns.status">Status</th><th data-i18n="users.columns.jobs">Jobs</th><th data-i18n="users.columns.cost">Cost</th><th data-i18n="users.columns.lastActive">Last Active</th><th data-i18n="users.columns.created">Created</th><th data-i18n="users.columns.actions">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
<div id="users-empty" class="empty-state" style="display:none" data-i18n="users.emptyState">No users found. Create the first user to get started.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+122
-4
@@ -569,6 +569,42 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
|
||||
const HOSTED_STATE_PREFIX: &str = "ic2";
|
||||
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
|
||||
|
||||
/// Maximum length for a legacy flow ID or instance name.
|
||||
const LEGACY_STATE_MAX_LEN: usize = 128;
|
||||
/// Minimum length for a legacy flow ID.
|
||||
const LEGACY_STATE_MIN_LEN: usize = 8;
|
||||
|
||||
/// Validate that a legacy state component (flow_id or instance_name) contains
|
||||
/// only safe characters: alphanumeric, dash, underscore.
|
||||
fn is_valid_legacy_state_component(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= LEGACY_STATE_MAX_LEN
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
|
||||
fn validate_legacy_flow_id(flow_id: &str) -> Result<(), String> {
|
||||
if flow_id.len() < LEGACY_STATE_MIN_LEN {
|
||||
return Err(format!(
|
||||
"Legacy OAuth flow_id too short ({} chars, minimum {LEGACY_STATE_MIN_LEN})",
|
||||
flow_id.len()
|
||||
));
|
||||
}
|
||||
if flow_id.len() > LEGACY_STATE_MAX_LEN {
|
||||
return Err(format!(
|
||||
"Legacy OAuth flow_id too long ({} chars, maximum {LEGACY_STATE_MAX_LEN})",
|
||||
flow_id.len()
|
||||
));
|
||||
}
|
||||
if !flow_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
return Err("Legacy OAuth flow_id contains invalid characters".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedHostedOAuthState {
|
||||
pub flow_id: String,
|
||||
@@ -653,6 +689,17 @@ pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState,
|
||||
if flow_id.is_empty() {
|
||||
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
|
||||
}
|
||||
validate_legacy_flow_id(flow_id)?;
|
||||
if !instance_name.is_empty() && !is_valid_legacy_state_component(instance_name) {
|
||||
return Err(format!(
|
||||
"Legacy OAuth instance name contains invalid characters or exceeds max length ({LEGACY_STATE_MAX_LEN})"
|
||||
));
|
||||
}
|
||||
tracing::debug!(
|
||||
flow_id,
|
||||
instance_name,
|
||||
"Decoded legacy prefixed OAuth state"
|
||||
);
|
||||
return Ok(DecodedHostedOAuthState {
|
||||
flow_id: flow_id.to_string(),
|
||||
instance_name: if instance_name.is_empty() {
|
||||
@@ -668,6 +715,9 @@ pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState,
|
||||
return Err("Hosted OAuth state is empty".to_string());
|
||||
}
|
||||
|
||||
validate_legacy_flow_id(state)?;
|
||||
tracing::debug!(flow_id = state, "Decoded legacy raw OAuth state");
|
||||
|
||||
Ok(DecodedHostedOAuthState {
|
||||
flow_id: state.to_string(),
|
||||
instance_name: None,
|
||||
@@ -1734,13 +1784,13 @@ mod tests {
|
||||
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
|
||||
assert_eq!(decoded.flow_id, "abc123");
|
||||
let decoded = decode_hosted_oauth_state("kind-deer:abc12345").expect("legacy prefixed");
|
||||
assert_eq!(decoded.flow_id, "abc12345");
|
||||
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
|
||||
assert!(decoded.is_legacy);
|
||||
|
||||
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
|
||||
assert_eq!(decoded.flow_id, "abc123");
|
||||
let decoded = decode_hosted_oauth_state("abc12345").expect("legacy raw");
|
||||
assert_eq!(decoded.flow_id, "abc12345");
|
||||
assert_eq!(decoded.instance_name, None);
|
||||
assert!(decoded.is_legacy);
|
||||
}
|
||||
@@ -1864,4 +1914,72 @@ mod tests {
|
||||
assert_eq!(decoded_no_instance.instance_name, None);
|
||||
assert!(!decoded_no_instance.is_legacy);
|
||||
}
|
||||
|
||||
/// Legacy flow IDs that are too short must be rejected (#1443).
|
||||
#[test]
|
||||
fn test_legacy_state_rejects_short_flow_id() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let err = decode_hosted_oauth_state("abc").expect_err("short raw flow_id");
|
||||
assert!(err.contains("too short"), "unexpected error: {err}");
|
||||
|
||||
let err = decode_hosted_oauth_state("inst:abc").expect_err("short prefixed flow_id");
|
||||
assert!(err.contains("too short"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
/// Legacy flow IDs with invalid characters must be rejected (#1443).
|
||||
#[test]
|
||||
fn test_legacy_state_rejects_invalid_characters() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let err = decode_hosted_oauth_state("flow id with spaces!").expect_err("spaces in flow_id");
|
||||
assert!(
|
||||
err.contains("invalid characters"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
let err = decode_hosted_oauth_state("inst:flow/id?bad=yes")
|
||||
.expect_err("special chars in prefixed flow_id");
|
||||
assert!(
|
||||
err.contains("invalid characters"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy instance names with invalid characters must be rejected (#1444).
|
||||
#[test]
|
||||
fn test_legacy_state_rejects_invalid_instance_name() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let err = decode_hosted_oauth_state("bad instance!:valid-flow-id-12345")
|
||||
.expect_err("invalid instance name");
|
||||
assert!(err.contains("instance name"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
/// Excessively long legacy flow IDs must be rejected (#1443).
|
||||
#[test]
|
||||
fn test_legacy_state_rejects_oversized_flow_id() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let long_id = "a".repeat(200);
|
||||
let err = decode_hosted_oauth_state(&long_id).expect_err("oversized flow_id");
|
||||
assert!(err.contains("too long"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
/// Valid legacy flow IDs at boundary lengths are accepted.
|
||||
#[test]
|
||||
fn test_legacy_state_accepts_boundary_lengths() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
// Exactly 8 chars (minimum)
|
||||
let decoded = decode_hosted_oauth_state("abcd1234").expect("8-char flow_id");
|
||||
assert_eq!(decoded.flow_id, "abcd1234");
|
||||
assert!(decoded.is_legacy);
|
||||
|
||||
// Exactly 128 chars (maximum)
|
||||
let max_id = "a".repeat(128);
|
||||
let decoded = decode_hosted_oauth_state(&max_id).expect("128-char flow_id");
|
||||
assert_eq!(decoded.flow_id, max_id);
|
||||
assert!(decoded.is_legacy);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -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<usize>,
|
||||
/// 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<usize>,
|
||||
@@ -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")?,
|
||||
})
|
||||
|
||||
+4
-65
@@ -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<String>,
|
||||
pub user_id: String,
|
||||
/// Additional user scopes for workspace reads.
|
||||
///
|
||||
/// When set, the workspace will be able to read (search, read, list) from
|
||||
/// these additional user scopes while writes remain isolated to `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<String>,
|
||||
/// Memory layer definitions (JSON in env var, or from external config).
|
||||
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
|
||||
/// Multi-user token map. When set, each token maps to a user identity.
|
||||
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
|
||||
/// to single-user mode via `auth_token` + `user_id`.
|
||||
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
|
||||
}
|
||||
|
||||
/// Per-user token configuration for multi-user mode.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UserTokenConfig {
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub workspace_read_scopes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
||||
@@ -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<crate::workspace::layer::MemoryLayer> =
|
||||
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<HashMap<String, UserTokenConfig>> =
|
||||
match optional_env("GATEWAY_USER_TOKENS")? {
|
||||
Some(json_str) => {
|
||||
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
|
||||
&json_str,
|
||||
)
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||
message: format!(
|
||||
"must be valid JSON object mapping tokens to user configs: {e}"
|
||||
),
|
||||
})?;
|
||||
if tokens.is_empty() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||
message:
|
||||
"token map is empty — remove the variable to use single-user mode"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
for (tok, cfg) in &tokens {
|
||||
if cfg.user_id.trim().is_empty() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||
message: format!(
|
||||
"token '{}...' has an empty user_id",
|
||||
&tok[..tok.len().min(8)]
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(tokens)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
@@ -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");
|
||||
|
||||
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
|
||||
pub quiet_hours_end: Option<u32>,
|
||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||
pub timezone: Option<String>,
|
||||
/// When true, cycle through all users with routines. 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)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Uuid> {
|
||||
self.contexts
|
||||
|
||||
@@ -67,19 +67,20 @@ impl ConversationStore for LibSqlBackend {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
source_channel: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
let affected = conn
|
||||
.execute(
|
||||
r#"
|
||||
INSERT INTO conversations (id, channel, user_id, thread_id, started_at, last_activity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
||||
INSERT INTO conversations (id, channel, user_id, thread_id, source_channel, started_at, last_activity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
||||
ON CONFLICT (id) DO UPDATE SET last_activity = excluded.last_activity
|
||||
WHERE conversations.user_id = excluded.user_id
|
||||
AND conversations.channel = excluded.channel
|
||||
"#,
|
||||
params![id.to_string(), channel, user_id, opt_text(thread_id), now],
|
||||
params![id.to_string(), channel, user_id, opt_text(thread_id), opt_text(source_channel), now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
@@ -565,6 +566,28 @@ impl ConversationStore for LibSqlBackend {
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
async fn get_conversation_source_channel(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
) -> Result<Option<String>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT source_channel FROM conversations WHERE id = ?1",
|
||||
params![conversation_id.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => Ok(get_opt_text(&row, 0)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+18
-13
@@ -12,11 +12,11 @@ mod routines;
|
||||
mod sandbox;
|
||||
mod settings;
|
||||
mod tool_failures;
|
||||
mod users;
|
||||
mod workspace;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
@@ -33,8 +33,6 @@ use crate::workspace::MemoryDocument;
|
||||
|
||||
use crate::db::libsql_migrations;
|
||||
|
||||
static NAIVE_TIMESTAMP_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Explicit column list for routines table (matches positional access in `row_to_routine_libsql`).
|
||||
pub(crate) const ROUTINE_COLUMNS: &str = "\
|
||||
id, name, description, user_id, enabled, \
|
||||
@@ -166,13 +164,11 @@ impl LibSqlBackend {
|
||||
///
|
||||
/// Returns an error if none of the formats match.
|
||||
pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
|
||||
let log_naive_timestamp_once = || {
|
||||
if !NAIVE_TIMESTAMP_LOGGED.swap(true, Ordering::Relaxed) {
|
||||
tracing::debug!(
|
||||
timestamp = %s,
|
||||
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
||||
);
|
||||
}
|
||||
let log_naive_timestamp = || {
|
||||
tracing::warn!(
|
||||
timestamp = %s,
|
||||
"parsed naive timestamp, assuming UTC — consider migrating to RFC 3339"
|
||||
);
|
||||
};
|
||||
|
||||
// RFC 3339 (our canonical write format)
|
||||
@@ -181,12 +177,12 @@ pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
|
||||
}
|
||||
// Naive with fractional seconds (legacy or SQLite datetime() output)
|
||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
|
||||
log_naive_timestamp_once();
|
||||
log_naive_timestamp();
|
||||
return Ok(ndt.and_utc());
|
||||
}
|
||||
// Naive without fractional seconds (legacy format)
|
||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
||||
log_naive_timestamp_once();
|
||||
log_naive_timestamp();
|
||||
return Ok(ndt.and_utc());
|
||||
}
|
||||
Err(format!("unparseable timestamp: {:?}", s))
|
||||
@@ -438,7 +434,7 @@ mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::db::libsql::{LibSqlBackend, normalize_notify_user, parse_timestamp};
|
||||
use crate::db::libsql::{LibSqlBackend, fmt_ts, normalize_notify_user, parse_timestamp};
|
||||
|
||||
#[test]
|
||||
fn test_normalize_notify_user_treats_legacy_default_as_missing() {
|
||||
@@ -467,6 +463,15 @@ mod tests {
|
||||
assert_eq!(naive_without_millis, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fmt_ts_roundtrips_through_parse_timestamp() {
|
||||
let original = Utc.with_ymd_and_hms(2026, 6, 15, 8, 30, 45).unwrap()
|
||||
+ chrono::Duration::milliseconds(123);
|
||||
let formatted = fmt_ts(&original);
|
||||
let parsed = parse_timestamp(&formatted).unwrap();
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_libsql_now_format_is_rfc3339_and_parseable() {
|
||||
let backend = LibSqlBackend::new_memory().await.unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
|
||||
-- ==================== User management (V14) ====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_login_at TEXT,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BLOB NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
"#;
|
||||
|
||||
/// Incremental migrations applied after the base schema.
|
||||
@@ -723,6 +753,46 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
|
||||
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
14,
|
||||
"users",
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_login_at TEXT,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BLOB NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
15,
|
||||
"conversation_source_channel",
|
||||
// Add source_channel to conversations for cross-channel approval authorization.
|
||||
r#"
|
||||
ALTER TABLE conversations ADD COLUMN source_channel TEXT;
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
+151
@@ -309,6 +309,43 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== User management record types ====================
|
||||
|
||||
/// A registered user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserRecord {
|
||||
/// User identifier (string, matches existing `user_id` throughout the codebase).
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub display_name: String,
|
||||
/// `active`, `suspended`, or `deactivated`.
|
||||
pub status: String,
|
||||
/// `admin` or `member`.
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
/// Who created/invited this user (nullable for bootstrap users).
|
||||
pub created_by: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
/// An API token for authenticating requests (hash stored, never plaintext).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiTokenRecord {
|
||||
pub id: Uuid,
|
||||
pub user_id: String,
|
||||
/// Human label (e.g. "my-laptop", "ci-bot").
|
||||
pub name: String,
|
||||
/// First 8 hex chars of the plaintext token for display/identification.
|
||||
pub token_prefix: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Soft-revoke timestamp. Non-null means revoked.
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ==================== Sub-traits ====================
|
||||
//
|
||||
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
||||
@@ -336,6 +373,7 @@ pub trait ConversationStore: Send + Sync {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
source_channel: Option<&str>,
|
||||
) -> Result<bool, DatabaseError>;
|
||||
async fn list_conversations_with_preview(
|
||||
&self,
|
||||
@@ -394,6 +432,11 @@ pub trait ConversationStore: Send + Sync {
|
||||
conversation_id: Uuid,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DatabaseError>;
|
||||
/// Get the source_channel for a conversation (the channel that created it).
|
||||
async fn get_conversation_source_channel(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
) -> Result<Option<String>, DatabaseError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -761,6 +804,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<Option<UserRecord>, DatabaseError>;
|
||||
/// Get a user by email address.
|
||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError>;
|
||||
/// List users, optionally filtered by status.
|
||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError>;
|
||||
/// Update a user's status (active/suspended/deactivated).
|
||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError>;
|
||||
/// Update a user's role (admin/member).
|
||||
async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError>;
|
||||
/// Update a user's display name and metadata.
|
||||
async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError>;
|
||||
/// Record a login timestamp.
|
||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError>;
|
||||
|
||||
// ---- API Tokens ----
|
||||
|
||||
/// Create a new API token. The `token_hash` is SHA-256 of the plaintext.
|
||||
async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError>;
|
||||
/// List tokens for a user (never includes the hash).
|
||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError>;
|
||||
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
|
||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError>;
|
||||
/// Look up a token by hash, returning the token record and its owning user.
|
||||
/// Only returns active (non-revoked, non-expired) tokens for active users.
|
||||
async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError>;
|
||||
/// Update `last_used_at` for a token.
|
||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
|
||||
|
||||
/// Check whether any user records exist (for first-run bootstrap detection).
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
|
||||
|
||||
/// Delete a user and all their data across all user-scoped tables.
|
||||
/// Returns false if the user doesn't exist.
|
||||
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError>;
|
||||
|
||||
/// Get per-user LLM usage stats for a time period.
|
||||
/// Aggregates from llm_calls via agent_jobs.user_id.
|
||||
async fn user_usage_stats(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<UserUsageStats>, DatabaseError>;
|
||||
|
||||
/// Lightweight per-user summary stats (job count, total cost, last active).
|
||||
/// Used by the admin users list to show inline stats.
|
||||
async fn user_summary_stats(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<UserSummaryStats>, DatabaseError>;
|
||||
|
||||
/// Create a user and their initial API token atomically.
|
||||
/// If either operation fails, both are rolled back.
|
||||
async fn create_user_with_token(
|
||||
&self,
|
||||
user: &UserRecord,
|
||||
token_name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError>;
|
||||
}
|
||||
|
||||
/// Per-user LLM usage statistics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserUsageStats {
|
||||
pub user_id: String,
|
||||
pub model: String,
|
||||
pub call_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub total_cost: Decimal,
|
||||
}
|
||||
|
||||
/// Lightweight per-user summary for the admin users list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserSummaryStats {
|
||||
pub user_id: String,
|
||||
/// Total agent jobs created by this user.
|
||||
pub job_count: i64,
|
||||
/// Total LLM spend across all jobs (all-time).
|
||||
pub total_cost: Decimal,
|
||||
/// Most recent activity (latest job or LLM call timestamp).
|
||||
pub last_active_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Backend-agnostic database supertrait.
|
||||
///
|
||||
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
|
||||
@@ -774,6 +924,7 @@ pub trait Database:
|
||||
+ ToolFailureStore
|
||||
+ SettingsStore
|
||||
+ WorkspaceStore
|
||||
+ UserStore
|
||||
+ Send
|
||||
+ Sync
|
||||
{
|
||||
|
||||
+125
-3
@@ -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::{
|
||||
@@ -99,9 +99,10 @@ impl ConversationStore for PgBackend {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
source_channel: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
self.store
|
||||
.ensure_conversation(id, channel, user_id, thread_id)
|
||||
.ensure_conversation(id, channel, user_id, thread_id, source_channel)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -212,6 +213,15 @@ impl ConversationStore for PgBackend {
|
||||
.conversation_belongs_to_user(conversation_id, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_conversation_source_channel(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
) -> Result<Option<String>, DatabaseError> {
|
||||
self.store
|
||||
.get_conversation_source_channel(conversation_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== JobStore ====================
|
||||
@@ -786,3 +796,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<Option<UserRecord>, DatabaseError> {
|
||||
self.store.get_user(id).await
|
||||
}
|
||||
|
||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
self.store.get_user_by_email(email).await
|
||||
}
|
||||
|
||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
||||
self.store.list_users(status).await
|
||||
}
|
||||
|
||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
||||
self.store.update_user_status(id, status).await
|
||||
}
|
||||
|
||||
async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
|
||||
self.store.update_user_role(id, role).await
|
||||
}
|
||||
|
||||
async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError> {
|
||||
self.store
|
||||
.update_user_profile(id, display_name, metadata)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
||||
self.store.record_login(id).await
|
||||
}
|
||||
|
||||
async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
self.store
|
||||
.create_api_token(user_id, name, token_hash, token_prefix, expires_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
||||
self.store.list_api_tokens(user_id).await
|
||||
}
|
||||
|
||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
|
||||
self.store.revoke_api_token(token_id, user_id).await
|
||||
}
|
||||
|
||||
async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
||||
self.store.authenticate_token(token_hash).await
|
||||
}
|
||||
|
||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
||||
self.store.record_token_usage(token_id).await
|
||||
}
|
||||
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
self.store.has_any_users().await
|
||||
}
|
||||
|
||||
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
|
||||
self.store.delete_user(id).await
|
||||
}
|
||||
|
||||
async fn user_usage_stats(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
|
||||
self.store.user_usage_stats(user_id, since).await
|
||||
}
|
||||
|
||||
async fn user_summary_stats(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
|
||||
self.store.user_summary_stats(user_id).await
|
||||
}
|
||||
|
||||
async fn create_user_with_token(
|
||||
&self,
|
||||
user: &UserRecord,
|
||||
token_name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
self.store
|
||||
.create_user_with_token(user, token_name, token_hash, token_prefix, expires_at)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
+16
-7
@@ -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<MakeRustlsConnect, rustls::Error> {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
|
||||
// Try native certs first.
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
for e in &native.errors {
|
||||
tracing::warn!("error loading system root certs: {e}");
|
||||
@@ -31,11 +38,14 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
|
||||
tracing::warn!("skipping invalid system root cert: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to bundled Mozilla roots when the system store is empty.
|
||||
if root_store.is_empty() {
|
||||
tracing::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<MakeRustlsConnect, rustls::Error> {
|
||||
/// 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());
|
||||
}
|
||||
|
||||
+649
-1
@@ -53,6 +53,38 @@ struct HostedOAuthFlowStart {
|
||||
flow: crate::cli::oauth_defaults::PendingOAuthFlow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct SecretCleanupPlan {
|
||||
base_secrets: HashSet<String>,
|
||||
companion_secrets: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
|
||||
impl SecretCleanupPlan {
|
||||
fn add_base_secret(&mut self, secret_name: impl AsRef<str>) {
|
||||
self.base_secrets
|
||||
.insert(secret_name.as_ref().to_lowercase());
|
||||
}
|
||||
|
||||
fn add_companion_secret(
|
||||
&mut self,
|
||||
base_secret_name: impl AsRef<str>,
|
||||
companion_secret_name: impl AsRef<str>,
|
||||
) {
|
||||
self.companion_secrets
|
||||
.entry(base_secret_name.as_ref().to_lowercase())
|
||||
.or_default()
|
||||
.insert(companion_secret_name.as_ref().to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_refresh_secret_name(secret_name: &str) -> String {
|
||||
format!("{}_refresh_token", secret_name.to_lowercase())
|
||||
}
|
||||
|
||||
fn oauth_scopes_secret_name(secret_name: &str) -> String {
|
||||
format!("{}_scopes", secret_name.to_lowercase())
|
||||
}
|
||||
|
||||
fn normalize_oauth_callback_path(path: &str) -> String {
|
||||
let trimmed_path = path.trim_end_matches('/');
|
||||
if trimmed_path.is_empty() {
|
||||
@@ -1602,6 +1634,10 @@ impl ExtensionManager {
|
||||
|
||||
match kind {
|
||||
ExtensionKind::McpServer => {
|
||||
let cleanup_plan = self
|
||||
.collect_secret_cleanup_plan(name, kind, user_id)
|
||||
.await?;
|
||||
|
||||
// Unregister tools with this server's prefix
|
||||
let tool_names: Vec<String> = self
|
||||
.tool_registry
|
||||
@@ -1623,6 +1659,9 @@ impl ExtensionManager {
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||
|
||||
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
|
||||
.await;
|
||||
|
||||
Ok(format!(
|
||||
"Removed MCP server '{}' and {} tool(s)",
|
||||
name,
|
||||
@@ -1630,6 +1669,10 @@ impl ExtensionManager {
|
||||
))
|
||||
}
|
||||
ExtensionKind::WasmTool => {
|
||||
let cleanup_plan = self
|
||||
.collect_secret_cleanup_plan(name, kind, user_id)
|
||||
.await?;
|
||||
|
||||
// Unregister from tool registry
|
||||
self.tool_registry.unregister(name).await;
|
||||
|
||||
@@ -1674,9 +1717,16 @@ impl ExtensionManager {
|
||||
let _ = tokio::fs::remove_file(&cap_path).await;
|
||||
}
|
||||
|
||||
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
|
||||
.await;
|
||||
|
||||
Ok(format!("Removed WASM tool '{}'", name))
|
||||
}
|
||||
ExtensionKind::WasmChannel => {
|
||||
let cleanup_plan = self
|
||||
.collect_secret_cleanup_plan(name, kind, user_id)
|
||||
.await?;
|
||||
|
||||
// Remove from active set and persist
|
||||
self.active_channel_names.write().await.remove(name);
|
||||
self.persist_active_channels(user_id).await;
|
||||
@@ -1702,6 +1752,9 @@ impl ExtensionManager {
|
||||
let _ = tokio::fs::remove_file(&cap_path).await;
|
||||
}
|
||||
|
||||
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
|
||||
.await;
|
||||
|
||||
Ok(format!(
|
||||
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
|
||||
name
|
||||
@@ -2999,6 +3052,258 @@ impl ExtensionManager {
|
||||
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
|
||||
}
|
||||
|
||||
async fn load_channel_capabilities(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Option<crate::channels::wasm::ChannelCapabilitiesFile> {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
let cap_bytes = tokio::fs::read(&cap_path).await.ok()?;
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes).ok()
|
||||
}
|
||||
|
||||
async fn collect_secret_cleanup_plan(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: ExtensionKind,
|
||||
user_id: &str,
|
||||
) -> Result<SecretCleanupPlan, ExtensionError> {
|
||||
let mut plan = SecretCleanupPlan::default();
|
||||
|
||||
match kind {
|
||||
ExtensionKind::WasmTool => {
|
||||
if let Some(cap) = self.load_tool_capabilities(name).await {
|
||||
for secret_name in Self::tool_secret_names(&cap) {
|
||||
plan.add_base_secret(secret_name);
|
||||
}
|
||||
|
||||
if let Some(auth) = cap.auth {
|
||||
plan.add_base_secret(&auth.secret_name);
|
||||
plan.add_companion_secret(
|
||||
&auth.secret_name,
|
||||
oauth_refresh_secret_name(&auth.secret_name),
|
||||
);
|
||||
plan.add_companion_secret(
|
||||
&auth.secret_name,
|
||||
oauth_scopes_secret_name(&auth.secret_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionKind::WasmChannel => {
|
||||
if let Some(cap) = self.load_channel_capabilities(name).await {
|
||||
for secret_name in Self::channel_secret_names(&cap) {
|
||||
plan.add_base_secret(secret_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
ExtensionKind::McpServer => {
|
||||
let server = self
|
||||
.get_mcp_server(name, user_id)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||
let token_secret_name = server.token_secret_name();
|
||||
plan.add_base_secret(&token_secret_name);
|
||||
plan.add_base_secret(server.client_id_secret_name());
|
||||
// MCP OAuth can persist companion secrets through two paths:
|
||||
// the MCP auth helper uses `mcp_<name>_refresh_token`, while the
|
||||
// hosted gateway callback stores companions alongside the access
|
||||
// token secret (`<token_secret>_refresh_token` / `_scopes`).
|
||||
plan.add_companion_secret(&token_secret_name, server.refresh_token_secret_name());
|
||||
plan.add_companion_secret(
|
||||
&token_secret_name,
|
||||
oauth_refresh_secret_name(&token_secret_name),
|
||||
);
|
||||
plan.add_companion_secret(
|
||||
&token_secret_name,
|
||||
oauth_scopes_secret_name(&token_secret_name),
|
||||
);
|
||||
}
|
||||
ExtensionKind::ChannelRelay => {}
|
||||
}
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
async fn cleanup_uninstalled_extension_secrets(&self, plan: SecretCleanupPlan, user_id: &str) {
|
||||
let referenced_secrets = match self.collect_referenced_secret_names(user_id).await {
|
||||
Ok(secret_names) => secret_names,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
user_id,
|
||||
error,
|
||||
"Failed to determine which secrets are still referenced; keeping secrets"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for base_secret in &plan.base_secrets {
|
||||
if referenced_secrets.contains(base_secret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.delete_secret_best_effort(user_id, base_secret).await;
|
||||
|
||||
if let Some(companion_secrets) = plan.companion_secrets.get(base_secret) {
|
||||
for companion_secret in companion_secrets {
|
||||
if !referenced_secrets.contains(companion_secret) {
|
||||
self.delete_secret_best_effort(user_id, companion_secret)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_secret_best_effort(&self, user_id: &str, secret_name: &str) {
|
||||
if let Err(error) = self.secrets.delete(user_id, secret_name).await {
|
||||
tracing::warn!(
|
||||
user_id,
|
||||
secret_name,
|
||||
error = %error,
|
||||
"Failed to delete secret while uninstalling extension"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_referenced_secret_names(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<HashSet<String>, String> {
|
||||
let mut referenced_secret_names = HashSet::new();
|
||||
|
||||
let tools = discover_tools(&self.wasm_tools_dir)
|
||||
.await
|
||||
.map_err(|e| format!("discover tools: {e}"))?;
|
||||
for (tool_name, discovered_tool) in &tools {
|
||||
let cap = self
|
||||
.load_tool_capabilities(tool_name)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
let path = discovered_tool
|
||||
.capabilities_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| format!("{} (missing)", tool_name));
|
||||
format!("load tool capabilities for {tool_name}: {path}")
|
||||
})?;
|
||||
referenced_secret_names.extend(Self::tool_secret_names(&cap));
|
||||
}
|
||||
|
||||
let channels = crate::channels::wasm::discover_channels(&self.wasm_channels_dir)
|
||||
.await
|
||||
.map_err(|e| format!("discover channels: {e}"))?;
|
||||
for (channel_name, discovered_channel) in &channels {
|
||||
let cap = self
|
||||
.load_channel_capabilities(channel_name)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
let path = discovered_channel
|
||||
.capabilities_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| format!("{} (missing)", channel_name));
|
||||
format!("load channel capabilities for {channel_name}: {path}")
|
||||
})?;
|
||||
referenced_secret_names.extend(Self::channel_secret_names(&cap));
|
||||
}
|
||||
|
||||
let mcp_servers = self
|
||||
.load_mcp_servers(user_id)
|
||||
.await
|
||||
.map_err(|e| format!("load MCP servers: {e}"))?;
|
||||
for server in &mcp_servers.servers {
|
||||
referenced_secret_names.extend(Self::mcp_server_secret_names(server));
|
||||
}
|
||||
|
||||
Ok(referenced_secret_names)
|
||||
}
|
||||
|
||||
fn tool_secret_names(cap: &crate::tools::wasm::CapabilitiesFile) -> HashSet<String> {
|
||||
let mut names = HashSet::new();
|
||||
|
||||
if let Some(auth) = &cap.auth {
|
||||
names.insert(auth.secret_name.to_lowercase());
|
||||
}
|
||||
if let Some(setup) = &cap.setup {
|
||||
names.extend(
|
||||
setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|secret| secret.name.to_lowercase()),
|
||||
);
|
||||
}
|
||||
if let Some(http) = &cap.http {
|
||||
names.extend(
|
||||
http.credentials
|
||||
.values()
|
||||
.map(|credential| credential.secret_name.to_lowercase()),
|
||||
);
|
||||
}
|
||||
if let Some(webhook) = &cap.webhook {
|
||||
if let Some(secret_name) = &webhook.secret_name {
|
||||
names.insert(secret_name.to_lowercase());
|
||||
}
|
||||
if let Some(secret_name) = &webhook.signature_key_secret_name {
|
||||
names.insert(secret_name.to_lowercase());
|
||||
}
|
||||
if let Some(secret_name) = &webhook.hmac_secret_name {
|
||||
names.insert(secret_name.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
fn channel_secret_names(
|
||||
cap: &crate::channels::wasm::ChannelCapabilitiesFile,
|
||||
) -> HashSet<String> {
|
||||
let mut names: HashSet<String> = cap
|
||||
.setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|secret| secret.name.to_lowercase())
|
||||
.collect();
|
||||
|
||||
if let Some(http) = cap.capabilities.tool.http.as_ref() {
|
||||
names.extend(
|
||||
http.credentials
|
||||
.values()
|
||||
.map(|credential| credential.secret_name.to_lowercase()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(webhook) = cap
|
||||
.capabilities
|
||||
.channel
|
||||
.as_ref()
|
||||
.and_then(|channel| channel.webhook.as_ref())
|
||||
{
|
||||
if webhook.secret_header.is_some() || webhook.secret_name.is_some() {
|
||||
names.insert(cap.webhook_secret_name().to_lowercase());
|
||||
}
|
||||
if let Some(secret_name) = cap.signature_key_secret_name() {
|
||||
names.insert(secret_name.to_lowercase());
|
||||
}
|
||||
if let Some(secret_name) = cap.hmac_secret_name() {
|
||||
names.insert(secret_name.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
names
|
||||
}
|
||||
|
||||
fn mcp_server_secret_names(server: &McpServerConfig) -> HashSet<String> {
|
||||
[
|
||||
server.token_secret_name().to_lowercase(),
|
||||
server.client_id_secret_name().to_lowercase(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect merged OAuth scopes from all installed tools sharing the same secret_name.
|
||||
///
|
||||
/// When multiple tools share an OAuth provider (e.g., google-calendar and google-drive
|
||||
@@ -6033,6 +6338,8 @@ mod tests {
|
||||
ExtensionError, ExtensionKind, ExtensionSource, InstallResult, VerificationChallenge,
|
||||
};
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::secrets::CreateSecretParams;
|
||||
use crate::tools::mcp::McpServerConfig;
|
||||
|
||||
fn require(condition: bool, message: impl Into<String>) -> Result<(), String> {
|
||||
if condition {
|
||||
@@ -6353,6 +6660,38 @@ mod tests {
|
||||
tools_dir
|
||||
}
|
||||
|
||||
fn write_test_channel(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
capabilities_json: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let channels_dir = dir.join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).expect("channels dir");
|
||||
std::fs::write(
|
||||
channels_dir.join(format!("{name}.wasm")),
|
||||
b"not-a-real-wasm",
|
||||
)
|
||||
.expect("wasm");
|
||||
std::fs::write(
|
||||
channels_dir.join(format!("{name}.capabilities.json")),
|
||||
capabilities_json,
|
||||
)
|
||||
.expect("capabilities");
|
||||
channels_dir
|
||||
}
|
||||
|
||||
async fn store_test_secret(
|
||||
manager: &crate::extensions::manager::ExtensionManager,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) {
|
||||
manager
|
||||
.secrets
|
||||
.create("test", CreateSecretParams::new(name, value))
|
||||
.await
|
||||
.expect("store secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setting_value_is_present() {
|
||||
assert!(
|
||||
@@ -7423,7 +7762,13 @@ mod tests {
|
||||
// Regression: remove() only checked channel_runtime for shutdown, missing
|
||||
// relay-only mode where only relay_channel_manager is set.
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let mgr = make_test_manager(None, dir.path().to_path_buf());
|
||||
let (store, _db_dir) = make_test_store().await;
|
||||
let mgr = make_test_manager_with_dirs(
|
||||
None,
|
||||
dir.path().join("tools"),
|
||||
dir.path().join("channels"),
|
||||
Some(store),
|
||||
);
|
||||
|
||||
// Set up relay channel manager with a stub channel
|
||||
let cm = Arc::new(crate::channels::ChannelManager::new());
|
||||
@@ -7450,6 +7795,8 @@ mod tests {
|
||||
.await
|
||||
.expect("store team_id");
|
||||
}
|
||||
store_test_secret(&mgr, "relay:slack-relay:oauth_state", "nonce").await;
|
||||
store_test_secret(&mgr, "relay:slack-relay:stream_token", "legacy-token").await;
|
||||
|
||||
// Verify channel exists before removal
|
||||
assert!(cm.get_channel("slack-relay").await.is_some());
|
||||
@@ -7478,6 +7825,30 @@ mod tests {
|
||||
cm.get_channel("slack-relay").await.is_none(),
|
||||
"relay channel should be removed from the channel manager"
|
||||
);
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", "relay:slack-relay:oauth_state")
|
||||
.await
|
||||
.expect("oauth state exists query"),
|
||||
"relay oauth_state secret should be removed"
|
||||
);
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", "relay:slack-relay:stream_token")
|
||||
.await
|
||||
.expect("stream token exists query"),
|
||||
"relay legacy stream token should be removed"
|
||||
);
|
||||
assert_eq!(
|
||||
mgr.store
|
||||
.as_ref()
|
||||
.expect("store")
|
||||
.get_setting("test", "relay:slack-relay:team_id")
|
||||
.await
|
||||
.expect("team_id query"),
|
||||
None,
|
||||
"relay team_id setting should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -7585,6 +7956,185 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_wasm_tool_deletes_unique_secrets() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let tools_dir = write_test_tool(
|
||||
dir.path(),
|
||||
"github",
|
||||
r#"{
|
||||
"name": "github",
|
||||
"auth": { "secret_name": "github_token" },
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{ "name": "github_client_secret", "prompt": "GitHub client secret for testing cleanup behavior." }
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"credentials": {
|
||||
"service_token": {
|
||||
"secret_name": "github_service_token",
|
||||
"location": { "type": "bearer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"hmac_secret_name": "github_webhook_secret"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
|
||||
|
||||
store_test_secret(&mgr, "github_token", "access-token").await;
|
||||
store_test_secret(&mgr, "github_token_refresh_token", "refresh-token").await;
|
||||
store_test_secret(&mgr, "github_token_scopes", "repo workflow").await;
|
||||
store_test_secret(&mgr, "github_client_secret", "client-secret").await;
|
||||
store_test_secret(&mgr, "github_service_token", "service-token").await;
|
||||
store_test_secret(&mgr, "github_webhook_secret", "webhook-secret").await;
|
||||
|
||||
mgr.remove("github", "test")
|
||||
.await
|
||||
.expect("remove should succeed");
|
||||
|
||||
for secret_name in [
|
||||
"github_token",
|
||||
"github_token_refresh_token",
|
||||
"github_token_scopes",
|
||||
"github_client_secret",
|
||||
"github_service_token",
|
||||
"github_webhook_secret",
|
||||
] {
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", secret_name)
|
||||
.await
|
||||
.expect("exists query"),
|
||||
"secret {secret_name} should be deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_wasm_tool_keeps_secrets_when_other_tool_capabilities_missing() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let tools_dir = write_test_tool(
|
||||
dir.path(),
|
||||
"github",
|
||||
r#"{
|
||||
"name": "github",
|
||||
"auth": { "secret_name": "shared_token" }
|
||||
}"#,
|
||||
);
|
||||
std::fs::write(tools_dir.join("broken.wasm"), b"fake-tool").expect("write tool");
|
||||
|
||||
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
|
||||
store_test_secret(&mgr, "shared_token", "access-token").await;
|
||||
store_test_secret(&mgr, "shared_token_refresh_token", "refresh-token").await;
|
||||
store_test_secret(&mgr, "shared_token_scopes", "repo").await;
|
||||
|
||||
mgr.remove("github", "test")
|
||||
.await
|
||||
.expect("remove should succeed");
|
||||
|
||||
for secret_name in [
|
||||
"shared_token",
|
||||
"shared_token_refresh_token",
|
||||
"shared_token_scopes",
|
||||
] {
|
||||
assert!(
|
||||
mgr.secrets
|
||||
.exists("test", secret_name)
|
||||
.await
|
||||
.expect("exists query"),
|
||||
"secret {secret_name} should be retained when reference detection is uncertain"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_wasm_tool_keeps_shared_secrets_until_last_extension() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
write_test_tool(
|
||||
dir.path(),
|
||||
"google-calendar",
|
||||
r#"{
|
||||
"name": "google-calendar",
|
||||
"auth": { "secret_name": "google_oauth_token" },
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{ "name": "google_oauth_client_id", "prompt": "Google OAuth client id for cleanup testing." },
|
||||
{ "name": "google_oauth_client_secret", "prompt": "Google OAuth client secret for cleanup testing." }
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let tools_dir = write_test_tool(
|
||||
dir.path(),
|
||||
"google-drive",
|
||||
r#"{
|
||||
"name": "google-drive",
|
||||
"auth": { "secret_name": "google_oauth_token" },
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{ "name": "google_oauth_client_id", "prompt": "Google OAuth client id for cleanup testing." },
|
||||
{ "name": "google_oauth_client_secret", "prompt": "Google OAuth client secret for cleanup testing." }
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
|
||||
|
||||
for (secret_name, value) in [
|
||||
("google_oauth_token", "access-token"),
|
||||
("google_oauth_token_refresh_token", "refresh-token"),
|
||||
("google_oauth_token_scopes", "calendar drive"),
|
||||
("google_oauth_client_id", "client-id"),
|
||||
("google_oauth_client_secret", "client-secret"),
|
||||
] {
|
||||
store_test_secret(&mgr, secret_name, value).await;
|
||||
}
|
||||
|
||||
mgr.remove("google-calendar", "test")
|
||||
.await
|
||||
.expect("first remove should succeed");
|
||||
|
||||
for secret_name in [
|
||||
"google_oauth_token",
|
||||
"google_oauth_token_refresh_token",
|
||||
"google_oauth_token_scopes",
|
||||
"google_oauth_client_id",
|
||||
"google_oauth_client_secret",
|
||||
] {
|
||||
assert!(
|
||||
mgr.secrets
|
||||
.exists("test", secret_name)
|
||||
.await
|
||||
.expect("exists query"),
|
||||
"shared secret {secret_name} should remain while google-drive is still installed"
|
||||
);
|
||||
}
|
||||
|
||||
mgr.remove("google-drive", "test")
|
||||
.await
|
||||
.expect("second remove should succeed");
|
||||
|
||||
for secret_name in [
|
||||
"google_oauth_token",
|
||||
"google_oauth_token_refresh_token",
|
||||
"google_oauth_token_scopes",
|
||||
"google_oauth_client_id",
|
||||
"google_oauth_client_secret",
|
||||
] {
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", secret_name)
|
||||
.await
|
||||
.expect("exists query"),
|
||||
"shared secret {secret_name} should be deleted after the last tool is removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_wasm_channel_clears_activation_error_and_deletes_files() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
@@ -7619,6 +8169,104 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_wasm_channel_deletes_setup_secrets() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let channels_dir = write_test_channel(
|
||||
dir.path(),
|
||||
"telegram",
|
||||
r#"{
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Telegram bot token used to verify uninstall cleanup behavior."
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"credentials": {
|
||||
"tenant_token": {
|
||||
"secret_name": "telegram_service_token",
|
||||
"location": { "type": "bearer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel": {
|
||||
"webhook": {
|
||||
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
|
||||
"secret_name": "telegram_webhook_secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let mgr = make_test_manager_with_dirs(None, dir.path().join("tools"), channels_dir, None);
|
||||
|
||||
store_test_secret(&mgr, "telegram_bot_token", "123:telegram-token").await;
|
||||
store_test_secret(&mgr, "telegram_service_token", "tenant-service-token").await;
|
||||
store_test_secret(&mgr, "telegram_webhook_secret", "webhook-secret").await;
|
||||
|
||||
mgr.remove("telegram", "test")
|
||||
.await
|
||||
.expect("remove should succeed");
|
||||
|
||||
for secret_name in [
|
||||
"telegram_bot_token",
|
||||
"telegram_service_token",
|
||||
"telegram_webhook_secret",
|
||||
] {
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", secret_name)
|
||||
.await
|
||||
.expect("exists query"),
|
||||
"channel secret {secret_name} should be deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_mcp_server_deletes_stored_secrets() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let (store, _db_dir) = make_test_store().await;
|
||||
let mgr = make_test_manager_with_dirs(
|
||||
None,
|
||||
dir.path().join("tools"),
|
||||
dir.path().join("channels"),
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
let server = McpServerConfig::new("notion", "https://example.com/mcp");
|
||||
mgr.add_mcp_server(server.clone(), "test")
|
||||
.await
|
||||
.expect("add mcp server");
|
||||
|
||||
store_test_secret(&mgr, &server.token_secret_name(), "access-token").await;
|
||||
store_test_secret(&mgr, &server.refresh_token_secret_name(), "refresh-token").await;
|
||||
store_test_secret(&mgr, &server.client_id_secret_name(), "client-id").await;
|
||||
|
||||
mgr.remove("notion", "test")
|
||||
.await
|
||||
.expect("remove should succeed");
|
||||
|
||||
for secret_name in [
|
||||
server.token_secret_name(),
|
||||
server.refresh_token_secret_name(),
|
||||
server.client_id_secret_name(),
|
||||
] {
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", &secret_name)
|
||||
.await
|
||||
.expect("exists query"),
|
||||
"MCP secret {secret_name} should be deleted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_url_with_query_params() {
|
||||
let url = "https://api.example.com/path?api_key=secret123&token=abc";
|
||||
|
||||
+559
-3
@@ -1581,19 +1581,20 @@ impl Store {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
source_channel: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let affected = conn
|
||||
.execute(
|
||||
r#"
|
||||
INSERT INTO conversations (id, channel, user_id, thread_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO conversations (id, channel, user_id, thread_id, source_channel)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET last_activity = NOW()
|
||||
WHERE conversations.user_id = EXCLUDED.user_id
|
||||
AND conversations.channel = EXCLUDED.channel
|
||||
"#,
|
||||
&[&id, &channel, &user_id, &thread_id],
|
||||
&[&id, &channel, &user_id, &thread_id, &source_channel],
|
||||
)
|
||||
.await?;
|
||||
Ok(affected > 0)
|
||||
@@ -1892,6 +1893,21 @@ impl Store {
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// Get the source_channel for a conversation.
|
||||
pub async fn get_conversation_source_channel(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
) -> Result<Option<String>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt(
|
||||
"SELECT source_channel FROM conversations WHERE id = $1",
|
||||
&[&conversation_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.and_then(|r| r.get::<_, Option<String>>(0)))
|
||||
}
|
||||
|
||||
/// Load messages for a conversation with cursor-based pagination.
|
||||
///
|
||||
/// Returns `(messages_oldest_first, has_more)`.
|
||||
@@ -2279,6 +2295,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<Option<UserRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
|
||||
.await?;
|
||||
Ok(row.map(|r| row_to_user(&r)))
|
||||
}
|
||||
|
||||
/// Get a user by email address.
|
||||
pub async fn get_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Option<UserRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
|
||||
.await?;
|
||||
Ok(row.map(|r| row_to_user(&r)))
|
||||
}
|
||||
|
||||
/// List users, optionally filtered by status.
|
||||
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = match status {
|
||||
Some(s) => {
|
||||
conn.query(
|
||||
"SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
|
||||
&[&s],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows.iter().map(row_to_user).collect())
|
||||
}
|
||||
|
||||
/// Update a user's status.
|
||||
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
|
||||
&[&status, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a user's role (admin/member).
|
||||
pub async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2",
|
||||
&[&role, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a user's display name and metadata.
|
||||
pub async fn update_user_profile(
|
||||
&self,
|
||||
id: &str,
|
||||
display_name: &str,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
|
||||
&[&display_name, metadata, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a login timestamp for a user.
|
||||
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new API token.
|
||||
pub async fn create_api_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
"#,
|
||||
&[
|
||||
&id,
|
||||
&user_id,
|
||||
&token_hash.as_slice(),
|
||||
&token_prefix,
|
||||
&name,
|
||||
&expires_at,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(ApiTokenRecord {
|
||||
id,
|
||||
user_id: user_id.to_string(),
|
||||
name: name.to_string(),
|
||||
token_prefix: token_prefix.to_string(),
|
||||
expires_at,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
revoked_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a user and their initial API token atomically in a single transaction.
|
||||
pub async fn create_user_with_token(
|
||||
&self,
|
||||
user: &UserRecord,
|
||||
token_name: &str,
|
||||
token_hash: &[u8; 32],
|
||||
token_prefix: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
||||
let mut conn = self.conn().await?;
|
||||
let tx = conn.transaction().await?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
&[
|
||||
&user.id,
|
||||
&user.email,
|
||||
&user.display_name,
|
||||
&user.status,
|
||||
&user.role,
|
||||
&user.created_at,
|
||||
&user.updated_at,
|
||||
&user.last_login_at,
|
||||
&user.created_by,
|
||||
&user.metadata,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
tx.execute(
|
||||
r#"
|
||||
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
"#,
|
||||
&[
|
||||
&id,
|
||||
&user.id,
|
||||
&token_hash.as_slice(),
|
||||
&token_prefix,
|
||||
&token_name,
|
||||
&expires_at,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(ApiTokenRecord {
|
||||
id,
|
||||
user_id: user.id.clone(),
|
||||
name: token_name.to_string(),
|
||||
token_prefix: token_prefix.to_string(),
|
||||
expires_at,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
revoked_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// List tokens for a user.
|
||||
pub async fn list_api_tokens(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.iter().map(row_to_api_token).collect())
|
||||
}
|
||||
|
||||
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
|
||||
pub async fn revoke_api_token(
|
||||
&self,
|
||||
token_id: Uuid,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let count = conn
|
||||
.execute(
|
||||
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
|
||||
&[&token_id, &user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Authenticate a token by hash. Returns the token record and its owning user
|
||||
/// if the token is active (non-revoked, non-expired) and the user is active.
|
||||
pub async fn authenticate_token(
|
||||
&self,
|
||||
token_hash: &[u8; 32],
|
||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at,
|
||||
u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
|
||||
FROM api_tokens t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.token_hash = $1
|
||||
AND t.revoked_at IS NULL
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
||||
AND u.status = 'active'
|
||||
"#,
|
||||
&[&token_hash.as_slice()],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| {
|
||||
let token = ApiTokenRecord {
|
||||
id: r.get("id"),
|
||||
user_id: r.get("user_id"),
|
||||
name: r.get("name"),
|
||||
token_prefix: r.get("token_prefix"),
|
||||
expires_at: r.get("expires_at"),
|
||||
last_used_at: r.get("last_used_at"),
|
||||
created_at: r.get("created_at"),
|
||||
revoked_at: r.get("revoked_at"),
|
||||
};
|
||||
let user = UserRecord {
|
||||
id: r.get("u_id"),
|
||||
email: r.get("email"),
|
||||
display_name: r.get("display_name"),
|
||||
status: r.get("status"),
|
||||
role: r.get("role"),
|
||||
created_at: r.get("u_created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
last_login_at: r.get("last_login_at"),
|
||||
created_by: r.get("created_by"),
|
||||
metadata: r.get("metadata"),
|
||||
};
|
||||
(token, user)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update `last_used_at` for a token.
|
||||
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
|
||||
&[&token_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether any user records exist.
|
||||
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.get("has_users"))
|
||||
}
|
||||
|
||||
/// Delete a user and all their data across all user-scoped tables.
|
||||
/// Returns false if the user doesn't exist.
|
||||
pub async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
|
||||
let mut conn = self.conn().await?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
// Delete from child tables first to avoid FK violations.
|
||||
// job_events must come before agent_jobs (FK without CASCADE).
|
||||
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots.
|
||||
// conversations cascades to conversation_messages.
|
||||
// memory_documents cascades to memory_chunks.
|
||||
// routines cascades to routine_runs.
|
||||
// api_tokens cascade automatically via FK on users.
|
||||
for table in &[
|
||||
"settings",
|
||||
"heartbeat_state",
|
||||
"tool_rate_limit_state",
|
||||
"secret_usage_log",
|
||||
"leak_detection_events",
|
||||
"secrets",
|
||||
"wasm_tools",
|
||||
"routines",
|
||||
"memory_documents",
|
||||
"conversations",
|
||||
] {
|
||||
tx.execute(&format!("DELETE FROM {table} WHERE user_id = $1"), &[&id])
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
}
|
||||
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
|
||||
tx.execute(
|
||||
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = $1)",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
tx.execute("DELETE FROM agent_jobs WHERE user_id = $1", &[&id])
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
// Nullify self-referencing created_by before deleting the user
|
||||
tx.execute(
|
||||
"UPDATE users SET created_by = NULL WHERE created_by = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
// api_tokens cascade automatically via FK
|
||||
let result = tx
|
||||
.execute("DELETE FROM users WHERE id = $1", &[&id])
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(result > 0)
|
||||
}
|
||||
|
||||
/// Get per-user LLM usage stats for a time period.
|
||||
/// Aggregates from llm_calls via agent_jobs.user_id.
|
||||
pub async fn user_usage_stats(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = if let Some(uid) = user_id {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT COALESCE(j.user_id, c.user_id) as user_id,
|
||||
l.model, COUNT(*) as call_count,
|
||||
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
|
||||
COALESCE(SUM(l.cost), 0) as total_cost
|
||||
FROM llm_calls l
|
||||
LEFT JOIN agent_jobs j ON l.job_id = j.id
|
||||
LEFT JOIN conversations c ON l.conversation_id = c.id
|
||||
WHERE l.created_at >= $1
|
||||
AND COALESCE(j.user_id, c.user_id) = $2
|
||||
GROUP BY COALESCE(j.user_id, c.user_id), l.model
|
||||
ORDER BY total_cost DESC
|
||||
"#,
|
||||
&[&since, &uid],
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT COALESCE(j.user_id, c.user_id) as user_id,
|
||||
l.model, COUNT(*) as call_count,
|
||||
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
|
||||
COALESCE(SUM(l.cost), 0) as total_cost
|
||||
FROM llm_calls l
|
||||
LEFT JOIN agent_jobs j ON l.job_id = j.id
|
||||
LEFT JOIN conversations c ON l.conversation_id = c.id
|
||||
WHERE l.created_at >= $1
|
||||
GROUP BY COALESCE(j.user_id, c.user_id), l.model
|
||||
ORDER BY total_cost DESC
|
||||
"#,
|
||||
&[&since],
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let mut stats = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
stats.push(crate::db::UserUsageStats {
|
||||
user_id: row.get("user_id"),
|
||||
model: row.get("model"),
|
||||
call_count: row.get("call_count"),
|
||||
input_tokens: row.get("input_tokens"),
|
||||
output_tokens: row.get("output_tokens"),
|
||||
total_cost: row.get("total_cost"),
|
||||
});
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Lightweight per-user summary stats (job count, total cost, last active).
|
||||
///
|
||||
/// Aggregates from `llm_calls`, resolving user_id via either `agent_jobs`
|
||||
/// (for background job calls) or `conversations` (for chat calls where
|
||||
/// `job_id` is NULL).
|
||||
pub async fn user_summary_stats(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = if let Some(uid) = user_id {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(j.user_id, c.user_id) AS user_id,
|
||||
COUNT(DISTINCT j.id) AS job_count,
|
||||
COALESCE(SUM(l.cost), 0) AS total_cost,
|
||||
MAX(l.created_at) AS last_active_at
|
||||
FROM llm_calls l
|
||||
LEFT JOIN agent_jobs j ON l.job_id = j.id
|
||||
LEFT JOIN conversations c ON l.conversation_id = c.id
|
||||
WHERE COALESCE(j.user_id, c.user_id) = $1
|
||||
GROUP BY COALESCE(j.user_id, c.user_id)
|
||||
"#,
|
||||
&[&uid],
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(j.user_id, c.user_id) AS user_id,
|
||||
COUNT(DISTINCT j.id) AS job_count,
|
||||
COALESCE(SUM(l.cost), 0) AS total_cost,
|
||||
MAX(l.created_at) AS last_active_at
|
||||
FROM llm_calls l
|
||||
LEFT JOIN agent_jobs j ON l.job_id = j.id
|
||||
LEFT JOIN conversations c ON l.conversation_id = c.id
|
||||
GROUP BY COALESCE(j.user_id, c.user_id)
|
||||
"#,
|
||||
&[],
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let mut stats = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
stats.push(crate::db::UserSummaryStats {
|
||||
user_id: row.get("user_id"),
|
||||
job_count: row.get("job_count"),
|
||||
total_cost: row.get("total_cost"),
|
||||
last_active_at: row.get("last_active_at"),
|
||||
});
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
|
||||
UserRecord {
|
||||
id: row.get("id"),
|
||||
email: row.get("email"),
|
||||
display_name: row.get("display_name"),
|
||||
status: row.get("status"),
|
||||
role: row.get("role"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
last_login_at: row.get("last_login_at"),
|
||||
created_by: row.get("created_by"),
|
||||
metadata: row.get("metadata"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
|
||||
ApiTokenRecord {
|
||||
id: row.get("id"),
|
||||
user_id: row.get("user_id"),
|
||||
name: row.get("name"),
|
||||
token_prefix: row.get("token_prefix"),
|
||||
expires_at: row.get("expires_at"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
created_at: row.get("created_at"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+55
-5
@@ -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<serde_json::Value>) -> RigRequest {
|
||||
RigRequest {
|
||||
preamble: None,
|
||||
chat_history: OneOrMany::one(RigMessage::user("test")),
|
||||
documents: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
tool_choice: None,
|
||||
additional_params,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_model_override_creates_params_when_none() {
|
||||
let mut req = make_rig_request(None);
|
||||
inject_model_override(&mut req, Some("test-model"));
|
||||
|
||||
let params = req
|
||||
.additional_params
|
||||
.expect("additional_params should be Some");
|
||||
assert_eq!(params, serde_json::json!({ "model": "test-model" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_model_override_preserves_existing_params() {
|
||||
let mut req = make_rig_request(Some(serde_json::json!({
|
||||
"cache_control": { "type": "ephemeral" },
|
||||
})));
|
||||
inject_model_override(&mut req, Some("override-model"));
|
||||
|
||||
let params = req.additional_params.expect("should remain Some");
|
||||
let obj = params.as_object().expect("should be object");
|
||||
assert_eq!(
|
||||
obj.get("cache_control"),
|
||||
Some(&serde_json::json!({ "type": "ephemeral" }))
|
||||
);
|
||||
assert_eq!(obj.get("model"), Some(&serde_json::json!("override-model")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_model_override_noop_when_none() {
|
||||
let mut req = make_rig_request(None);
|
||||
inject_model_override(&mut req, None);
|
||||
assert!(req.additional_params.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+51
-27
@@ -449,6 +449,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
&components.secrets_store,
|
||||
components.extension_manager.as_ref(),
|
||||
components.db.as_ref(),
|
||||
&channel_names,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -591,27 +592,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let mut gateway_url: Option<String> = None;
|
||||
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
// 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 +631,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 +820,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
|
||||
|
||||
@@ -269,10 +269,6 @@ pub struct ChannelSettings {
|
||||
#[serde(default)]
|
||||
pub gateway_auth_token: Option<String>,
|
||||
|
||||
/// Web gateway user ID.
|
||||
#[serde(default)]
|
||||
pub gateway_user_id: Option<String>,
|
||||
|
||||
/// Whether the CLI channel is enabled.
|
||||
#[serde(default = "default_true")]
|
||||
pub cli_enabled: bool,
|
||||
@@ -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,
|
||||
|
||||
+50
-2
@@ -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<Uuid, DatabaseError> {
|
||||
self.inner.record_llm_call(record).await
|
||||
}
|
||||
|
||||
// === Settings ===
|
||||
|
||||
pub async fn get_setting(&self, key: &str) -> Result<Option<serde_json::Value>, DatabaseError> {
|
||||
@@ -271,7 +278,7 @@ impl TenantScope {
|
||||
thread_id: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
self.inner
|
||||
.ensure_conversation(id, channel, &self.user_id, thread_id)
|
||||
.ensure_conversation(id, channel, &self.user_id, thread_id, None)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -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<Uuid, DatabaseError> {
|
||||
if !self.conversation_belongs_to_user(conversation_id).await? {
|
||||
return Err(DatabaseError::NotFound {
|
||||
entity: "conversation".to_string(),
|
||||
id: conversation_id.to_string(),
|
||||
});
|
||||
}
|
||||
self.inner
|
||||
.add_conversation_message(conversation_id, role, content)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Touch a conversation timestamp. Returns `NotFound` if not owned by this user.
|
||||
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
|
||||
if !self.conversation_belongs_to_user(id).await? {
|
||||
return Err(DatabaseError::NotFound {
|
||||
entity: "conversation".to_string(),
|
||||
id: id.to_string(),
|
||||
});
|
||||
}
|
||||
self.inner.touch_conversation(id).await
|
||||
}
|
||||
|
||||
/// List messages in a conversation. Returns `NotFound` if not owned by this user.
|
||||
pub async fn list_conversation_messages(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
) -> Result<Vec<ConversationMessage>, DatabaseError> {
|
||||
if !self.conversation_belongs_to_user(conversation_id).await? {
|
||||
return Err(DatabaseError::NotFound {
|
||||
entity: "conversation".to_string(),
|
||||
id: conversation_id.to_string(),
|
||||
});
|
||||
}
|
||||
self.inner.list_conversation_messages(conversation_id).await
|
||||
}
|
||||
|
||||
/// Paginated message listing. Returns `NotFound` if not owned by this user.
|
||||
pub async fn list_conversation_messages_paginated(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
|
||||
if !self.conversation_belongs_to_user(conversation_id).await? {
|
||||
return Err(DatabaseError::NotFound {
|
||||
entity: "conversation".to_string(),
|
||||
id: conversation_id.to_string(),
|
||||
});
|
||||
}
|
||||
self.inner
|
||||
.list_conversation_messages_paginated(conversation_id, before, limit)
|
||||
.await
|
||||
@@ -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<Option<serde_json::Value>, DatabaseError> {
|
||||
if !self.conversation_belongs_to_user(id).await? {
|
||||
return Err(DatabaseError::NotFound {
|
||||
entity: "conversation".to_string(),
|
||||
id: id.to_string(),
|
||||
});
|
||||
}
|
||||
self.inner.get_conversation_metadata(id).await
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -753,7 +753,7 @@ mod tests {
|
||||
|
||||
// ensure_conversation should create the row.
|
||||
assert!(
|
||||
db.ensure_conversation(conv_id, "web", "carol", None)
|
||||
db.ensure_conversation(conv_id, "web", "carol", None, Some("web"))
|
||||
.await
|
||||
.expect("ensure first"),
|
||||
"first ensure_conversation should create the row"
|
||||
@@ -761,7 +761,7 @@ mod tests {
|
||||
|
||||
// Calling again with the same ID should not error.
|
||||
assert!(
|
||||
db.ensure_conversation(conv_id, "web", "carol", None)
|
||||
db.ensure_conversation(conv_id, "web", "carol", None, Some("web"))
|
||||
.await
|
||||
.expect("ensure second (idempotent)"),
|
||||
"second ensure_conversation should touch owned row"
|
||||
@@ -806,7 +806,7 @@ mod tests {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
|
||||
assert!(
|
||||
!db.ensure_conversation(conv_id, "web", "mallory", None)
|
||||
!db.ensure_conversation(conv_id, "web", "mallory", None, None)
|
||||
.await
|
||||
.expect("foreign ensure should not error"),
|
||||
"foreign ensure_conversation should report not ensured"
|
||||
|
||||
@@ -124,8 +124,10 @@ impl WasmToolLoader {
|
||||
let wasm_bytes = fs::read(wasm_path).await?;
|
||||
|
||||
// Read capabilities (optional) and extract OAuth refresh config
|
||||
// and tool description. Parameter schema is auto-derived from the
|
||||
// WASM module's schema() export (see WasmToolSchemas::compact_schema).
|
||||
// and tool description. Parameter schema is NOT read from the
|
||||
// capabilities file — it is auto-derived from the WASM module's
|
||||
// schema() export at prepare time (see WasmToolSchemas::compact_schema),
|
||||
// so no schema override is needed here.
|
||||
let (capabilities, oauth_refresh, description) = if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
|
||||
@@ -759,14 +759,31 @@ impl WasmToolSchemas {
|
||||
}
|
||||
|
||||
let kept: serde_json::Map<String, serde_json::Value> = all_properties
|
||||
.into_iter()
|
||||
.iter()
|
||||
.filter(|(name, prop)| {
|
||||
required.contains(name) || prop.get("enum").is_some() || prop.get("const").is_some()
|
||||
required.contains(name.as_str())
|
||||
|| prop.get("enum").is_some()
|
||||
|| prop.get("const").is_some()
|
||||
})
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
if kept.is_empty() {
|
||||
return Self::permissive_schema();
|
||||
// When the schema has typed properties but none survived the
|
||||
// required/enum filter, include all typed properties so the LLM
|
||||
// sees meaningful parameter hints instead of permissive `{}`.
|
||||
let typed: serde_json::Map<String, serde_json::Value> = all_properties
|
||||
.into_iter()
|
||||
.filter(|(_, prop)| schema_is_typed_property(prop))
|
||||
.collect();
|
||||
if typed.is_empty() {
|
||||
return Self::permissive_schema();
|
||||
}
|
||||
return serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": typed,
|
||||
"additionalProperties": true,
|
||||
});
|
||||
}
|
||||
|
||||
let kept_required: Vec<serde_json::Value> = required
|
||||
@@ -1991,6 +2008,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typed_schema_without_required_is_advertised() {
|
||||
// Regression test for #1303: when a WASM tool exports a typed schema
|
||||
// with no required/enum fields, the advertised schema should still
|
||||
// contain the typed properties instead of falling back to permissive {}.
|
||||
let discovery_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string" },
|
||||
"limit": { "type": "integer" }
|
||||
}
|
||||
});
|
||||
|
||||
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
|
||||
let prepared = runtime
|
||||
.prepare("typed_search", b"\0asm\x0d\0\x01\0", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut wrapper =
|
||||
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
|
||||
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
|
||||
wrapper.description = "Typed search tool".to_string();
|
||||
|
||||
let advertised = wrapper.parameters_schema();
|
||||
let props = advertised["properties"].as_object().unwrap();
|
||||
|
||||
// Both typed properties should be preserved in the advertised schema
|
||||
assert!(
|
||||
props.contains_key("query"),
|
||||
"advertised schema should contain 'query' property"
|
||||
);
|
||||
assert!(
|
||||
props.contains_key("limit"),
|
||||
"advertised schema should contain 'limit' property"
|
||||
);
|
||||
assert_eq!(props.len(), 2);
|
||||
|
||||
// The schema should NOT be permissive
|
||||
assert!(
|
||||
!super::WasmToolSchemas::is_permissive_schema(&advertised),
|
||||
"advertised schema should not be permissive when typed properties exist"
|
||||
);
|
||||
|
||||
// No tool_info hint needed since typed properties are visible
|
||||
let schema = wrapper.schema();
|
||||
assert!(
|
||||
!schema.description.contains("tool_info"),
|
||||
"description should not contain tool_info hint: {}",
|
||||
schema.description
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_schema_keeps_required_and_enum_properties() {
|
||||
let schema = serde_json::json!({
|
||||
@@ -2028,8 +2097,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_schema_falls_back_to_permissive_when_empty() {
|
||||
// No required, no enum → permissive fallback
|
||||
fn test_compact_schema_preserves_typed_properties_when_no_required() {
|
||||
// No required, no enum, but typed properties → keep all typed props
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2038,6 +2107,24 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let compacted = super::WasmToolSchemas::compact_schema(&schema);
|
||||
let props = compacted["properties"].as_object().unwrap();
|
||||
assert_eq!(props.len(), 2);
|
||||
assert!(props.contains_key("query"));
|
||||
assert!(props.contains_key("limit"));
|
||||
assert_eq!(compacted["additionalProperties"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_schema_falls_back_to_permissive_when_no_typed_properties() {
|
||||
// Properties with no type info → permissive fallback
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {}
|
||||
}
|
||||
});
|
||||
|
||||
let compacted = super::WasmToolSchemas::compact_schema(&schema);
|
||||
assert!(compacted["properties"].as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ HEADED=1 pytest scenarios/
|
||||
| `test_skills.py` | Skills tab UI visibility, ClawHub search (skipped if registry unreachable), install + remove lifecycle |
|
||||
| `test_sse_reconnect.py` | SSE reconnects after programmatic `eventSource.close()` + `connectSSE()`; history is reloaded after reconnect |
|
||||
| `test_tool_approval.py` | Approval card appears, buttons disable on approve/deny, parameters toggle via `page.evaluate("showApproval(...)")`; the waiting-approval regression uses a real HTTP tool call |
|
||||
| `test_extension_uninstall_cleanup.py` | Real install/setup/remove coverage for WASM tools, WASM channels, OAuth-backed shared Google tools, and MCP servers; verifies uninstall deletes stored secrets from the libSQL `secrets` table while preserving shared credentials until the last referencing extension is removed |
|
||||
| `test_oauth_refresh.py` | Hosted Gmail OAuth regression: complete setup via `/oauth/callback`, expire the stored access token in libSQL, trigger a real `gmail` tool call through `/api/chat/send`, and verify refresh goes through the mock `/oauth/refresh` proxy without forwarding `client_secret` |
|
||||
|
||||
## `helpers.py`
|
||||
@@ -77,6 +78,7 @@ All fixtures are defined in `tests/e2e/conftest.py`. Running `pytest scenarios/`
|
||||
| `mock_llm_server` | Starts `mock_llm.py --port 0`, reads the assigned port from stdout, waits for `/v1/models` to return 200. Yields the base URL. |
|
||||
| `ironclaw_server` | Starts the ironclaw binary with a minimal env (see below), waits for `/api/health` (timeout 60s). Yields the base URL. On teardown sends **SIGINT** (not SIGTERM) so the tokio ctrl_c handler triggers a graceful shutdown and LLVM coverage data is flushed. |
|
||||
| `hosted_oauth_refresh_server` | Starts a second ironclaw instance with a dedicated libSQL DB and `GOOGLE_OAUTH_CLIENT_ID=hosted-google-client-id`, while still pointing `IRONCLAW_OAUTH_EXCHANGE_URL` at `mock_llm.py`. Yields a dict with `base_url`, `db_path`, `gateway_user_id`, and `mock_llm_url` for the hosted refresh regression scenario. |
|
||||
| `extension_cleanup_server` | Starts an isolated ironclaw instance with its own temp DB/home/WASM dirs, `SECRETS_MASTER_KEY`, and hosted-style OAuth env so uninstall-cleanup scenarios can inspect the `secrets` table without interfering with the shared E2E server state. |
|
||||
| `browser` | Launches a single Chromium instance (headless by default; set `HEADED=1` for headed). Shared across all tests. |
|
||||
|
||||
### Function-scoped fixtures
|
||||
|
||||
+109
-4
@@ -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:
|
||||
@@ -446,6 +443,115 @@ async def hosted_oauth_refresh_server(
|
||||
home_tmpdir.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def extension_cleanup_server(
|
||||
ironclaw_binary,
|
||||
mock_llm_server,
|
||||
):
|
||||
"""Start an isolated ironclaw instance for uninstall secret cleanup E2E tests."""
|
||||
reserved = _reserve_loopback_sockets(2)
|
||||
db_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-db-")
|
||||
home_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-home-")
|
||||
tools_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-tools-")
|
||||
channels_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-channels-")
|
||||
|
||||
try:
|
||||
gateway_port = reserved[0].getsockname()[1]
|
||||
http_port = reserved[1].getsockname()[1]
|
||||
for sock in reserved:
|
||||
if sock.fileno() != -1:
|
||||
sock.close()
|
||||
|
||||
db_path = os.path.join(db_tmpdir.name, "extension-cleanup.db")
|
||||
home_dir = home_tmpdir.name
|
||||
env = {
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"HOME": home_dir,
|
||||
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
|
||||
"RUST_LOG": "ironclaw=info",
|
||||
"RUST_BACKTRACE": "1",
|
||||
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_HOST": "127.0.0.1",
|
||||
"GATEWAY_PORT": str(gateway_port),
|
||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
|
||||
"HTTP_HOST": "127.0.0.1",
|
||||
"HTTP_PORT": str(http_port),
|
||||
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
||||
"CLI_ENABLED": "false",
|
||||
"LLM_BACKEND": "openai_compatible",
|
||||
"LLM_BASE_URL": mock_llm_server,
|
||||
"LLM_MODEL": "mock-model",
|
||||
"DATABASE_BACKEND": "libsql",
|
||||
"LIBSQL_PATH": db_path,
|
||||
"SECRETS_MASTER_KEY": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
"ROUTINES_ENABLED": "true",
|
||||
"HEARTBEAT_ENABLED": "false",
|
||||
"EMBEDDING_ENABLED": "false",
|
||||
"WASM_ENABLED": "true",
|
||||
"WASM_TOOLS_DIR": tools_tmpdir.name,
|
||||
"WASM_CHANNELS_DIR": channels_tmpdir.name,
|
||||
"ONBOARD_COMPLETED": "true",
|
||||
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
|
||||
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
|
||||
"GOOGLE_OAUTH_CLIENT_ID": "hosted-google-client-id",
|
||||
}
|
||||
_forward_coverage_env(env)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ironclaw_binary, "--no-onboard",
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
startup_kill_attempted = False
|
||||
base_url = f"http://127.0.0.1:{gateway_port}"
|
||||
try:
|
||||
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
||||
yield {
|
||||
"base_url": base_url,
|
||||
"db_path": db_path,
|
||||
"gateway_user_id": OWNER_SCOPE_ID,
|
||||
"mock_llm_url": mock_llm_server,
|
||||
}
|
||||
except TimeoutError:
|
||||
if proc.returncode is None:
|
||||
startup_kill_attempted = True
|
||||
await _stop_process(proc, timeout=2)
|
||||
returncode = proc.returncode
|
||||
stderr_bytes = b""
|
||||
if proc.stderr:
|
||||
try:
|
||||
stderr_bytes = await asyncio.wait_for(proc.stderr.read(8192), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
||||
pytest.fail(
|
||||
f"extension cleanup server failed to start on port {gateway_port} "
|
||||
f"(returncode={returncode}).\nstderr:\n{stderr_text}"
|
||||
)
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
if startup_kill_attempted:
|
||||
await _stop_process(proc, timeout=2)
|
||||
else:
|
||||
await _stop_process(proc, sig=signal.SIGINT, timeout=10)
|
||||
if proc.returncode is None:
|
||||
await _stop_process(proc, timeout=2)
|
||||
finally:
|
||||
for sock in reserved:
|
||||
if sock.fileno() != -1:
|
||||
sock.close()
|
||||
db_tmpdir.cleanup()
|
||||
home_tmpdir.cleanup()
|
||||
tools_tmpdir.cleanup()
|
||||
channels_tmpdir.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def http_channel_server(ironclaw_server, server_ports):
|
||||
"""HTTP webhook channel base URL."""
|
||||
@@ -473,7 +579,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",
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Extension uninstall secret cleanup E2E tests.
|
||||
|
||||
Exercises real install/setup/auth/remove flows and verifies the backing
|
||||
secrets table is cleaned up when extensions are uninstalled.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from helpers import api_get, api_post
|
||||
|
||||
|
||||
def _extract_state(auth_url: str) -> str:
|
||||
parsed = urlparse(auth_url)
|
||||
state = parse_qs(parsed.query).get("state", [None])[0]
|
||||
assert state, f"auth_url should include state: {auth_url}"
|
||||
return state
|
||||
|
||||
|
||||
def _secret_exists(db_path: str, user_id: str, name: str) -> bool:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2 LIMIT 1",
|
||||
(user_id, name),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _secret_names(db_path: str, user_id: str) -> set[str]:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM secrets WHERE user_id = ?1",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
|
||||
async def _get_extension(base_url: str, name: str) -> dict | None:
|
||||
response = await api_get(base_url, "/api/extensions", timeout=15)
|
||||
response.raise_for_status()
|
||||
for extension in response.json().get("extensions", []):
|
||||
if extension["name"] == name:
|
||||
return extension
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_removed(base_url: str, name: str) -> None:
|
||||
extension = await _get_extension(base_url, name)
|
||||
if extension is not None:
|
||||
response = await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json().get("success") is True, response.text
|
||||
|
||||
|
||||
async def _install_extension(
|
||||
base_url: str,
|
||||
name: str,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> None:
|
||||
payload = {"name": name}
|
||||
if kind is not None:
|
||||
payload["kind"] = kind
|
||||
if url is not None:
|
||||
payload["url"] = url
|
||||
|
||||
response = await api_post(
|
||||
base_url,
|
||||
"/api/extensions/install",
|
||||
json=payload,
|
||||
timeout=180,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json().get("success") is True, response.text
|
||||
|
||||
|
||||
async def test_remove_wasm_tool_deletes_unique_secret(extension_cleanup_server):
|
||||
server = extension_cleanup_server["base_url"]
|
||||
db_path = extension_cleanup_server["db_path"]
|
||||
user_id = extension_cleanup_server["gateway_user_id"]
|
||||
|
||||
await _ensure_removed(server, "web-search")
|
||||
|
||||
await _install_extension(server, "web-search")
|
||||
|
||||
setup_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/web-search/setup",
|
||||
json={"secrets": {"brave_api_key": "cleanup-test-key"}},
|
||||
timeout=30,
|
||||
)
|
||||
assert setup_response.status_code == 200, setup_response.text
|
||||
assert setup_response.json().get("success") is True, setup_response.text
|
||||
assert _secret_exists(db_path, user_id, "brave_api_key")
|
||||
|
||||
remove_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/web-search/remove",
|
||||
timeout=30,
|
||||
)
|
||||
assert remove_response.status_code == 200, remove_response.text
|
||||
assert remove_response.json().get("success") is True, remove_response.text
|
||||
assert not _secret_exists(db_path, user_id, "brave_api_key")
|
||||
|
||||
|
||||
async def test_remove_wasm_channel_deletes_setup_secrets(extension_cleanup_server):
|
||||
server = extension_cleanup_server["base_url"]
|
||||
db_path = extension_cleanup_server["db_path"]
|
||||
user_id = extension_cleanup_server["gateway_user_id"]
|
||||
|
||||
await _ensure_removed(server, "discord")
|
||||
|
||||
await _install_extension(server, "discord", kind="wasm_channel")
|
||||
|
||||
setup_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/discord/setup",
|
||||
json={
|
||||
"secrets": {
|
||||
"discord_bot_token": "cleanup-discord-bot-token",
|
||||
"discord_public_key": "cleanup-discord-public-key",
|
||||
}
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
assert setup_response.status_code == 200, setup_response.text
|
||||
assert setup_response.json().get("success") is True, setup_response.text
|
||||
assert _secret_exists(db_path, user_id, "discord_bot_token")
|
||||
assert _secret_exists(db_path, user_id, "discord_public_key")
|
||||
|
||||
remove_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/discord/remove",
|
||||
timeout=30,
|
||||
)
|
||||
assert remove_response.status_code == 200, remove_response.text
|
||||
assert remove_response.json().get("success") is True, remove_response.text
|
||||
assert not _secret_exists(db_path, user_id, "discord_bot_token")
|
||||
assert not _secret_exists(db_path, user_id, "discord_public_key")
|
||||
|
||||
|
||||
async def test_remove_shared_google_oauth_secrets_after_last_tool(extension_cleanup_server):
|
||||
server = extension_cleanup_server["base_url"]
|
||||
db_path = extension_cleanup_server["db_path"]
|
||||
user_id = extension_cleanup_server["gateway_user_id"]
|
||||
|
||||
await _ensure_removed(server, "gmail")
|
||||
await _ensure_removed(server, "google-drive")
|
||||
|
||||
await _install_extension(server, "gmail")
|
||||
await _install_extension(server, "google-drive")
|
||||
|
||||
setup_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/gmail/setup",
|
||||
json={"secrets": {}},
|
||||
timeout=30,
|
||||
)
|
||||
assert setup_response.status_code == 200, setup_response.text
|
||||
auth_url = setup_response.json().get("auth_url")
|
||||
assert auth_url, setup_response.text
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
callback_response = await client.get(
|
||||
f"{server}/oauth/callback",
|
||||
params={"code": "mock_auth_code", "state": _extract_state(auth_url)},
|
||||
timeout=30,
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert callback_response.status_code == 200, callback_response.text[:400]
|
||||
|
||||
shared_secrets = [
|
||||
"google_oauth_token",
|
||||
"google_oauth_token_refresh_token",
|
||||
"google_oauth_token_scopes",
|
||||
]
|
||||
for secret_name in shared_secrets:
|
||||
assert _secret_exists(db_path, user_id, secret_name), f"expected {secret_name} to exist"
|
||||
|
||||
gmail_remove_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/gmail/remove",
|
||||
timeout=30,
|
||||
)
|
||||
assert gmail_remove_response.status_code == 200, gmail_remove_response.text
|
||||
assert gmail_remove_response.json().get("success") is True, gmail_remove_response.text
|
||||
for secret_name in shared_secrets:
|
||||
assert _secret_exists(db_path, user_id, secret_name), (
|
||||
f"{secret_name} should remain while google-drive is still installed"
|
||||
)
|
||||
|
||||
drive_remove_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/google-drive/remove",
|
||||
timeout=30,
|
||||
)
|
||||
assert drive_remove_response.status_code == 200, drive_remove_response.text
|
||||
assert drive_remove_response.json().get("success") is True, drive_remove_response.text
|
||||
for secret_name in shared_secrets:
|
||||
assert not _secret_exists(db_path, user_id, secret_name), (
|
||||
f"{secret_name} should be deleted after the last Google tool is removed"
|
||||
)
|
||||
|
||||
|
||||
async def test_remove_mcp_server_deletes_stored_secrets(extension_cleanup_server):
|
||||
server = extension_cleanup_server["base_url"]
|
||||
db_path = extension_cleanup_server["db_path"]
|
||||
user_id = extension_cleanup_server["gateway_user_id"]
|
||||
mcp_url = f"{extension_cleanup_server['mock_llm_url']}/mcp"
|
||||
|
||||
await _ensure_removed(server, "mock-mcp")
|
||||
|
||||
await _install_extension(server, "mock-mcp", kind="mcp_server", url=mcp_url)
|
||||
|
||||
setup_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/mock-mcp/setup",
|
||||
json={"secrets": {}},
|
||||
timeout=30,
|
||||
)
|
||||
assert setup_response.status_code == 200, setup_response.text
|
||||
auth_url = setup_response.json().get("auth_url")
|
||||
if auth_url is None:
|
||||
activate_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/mock-mcp/activate",
|
||||
timeout=30,
|
||||
)
|
||||
assert activate_response.status_code == 200, activate_response.text
|
||||
auth_url = activate_response.json().get("auth_url")
|
||||
assert auth_url, "mock-mcp should require OAuth in E2E"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
callback_response = await client.get(
|
||||
f"{server}/oauth/callback",
|
||||
params={"code": "mock_mcp_code", "state": _extract_state(auth_url)},
|
||||
timeout=30,
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert callback_response.status_code == 200, callback_response.text[:400]
|
||||
|
||||
expected_mcp_secrets = [
|
||||
"mcp_mock-mcp_access_token",
|
||||
"mcp_mock-mcp_client_id",
|
||||
]
|
||||
stored_secret_names = _secret_names(db_path, user_id)
|
||||
for secret_name in expected_mcp_secrets:
|
||||
assert secret_name in stored_secret_names, (
|
||||
f"expected {secret_name} to exist; stored secrets were {sorted(stored_secret_names)}"
|
||||
)
|
||||
|
||||
remove_response = await api_post(
|
||||
server,
|
||||
"/api/extensions/mock-mcp/remove",
|
||||
timeout=30,
|
||||
)
|
||||
assert remove_response.status_code == 200, remove_response.text
|
||||
assert remove_response.json().get("success") is True, remove_response.text
|
||||
remaining_secret_names = _secret_names(db_path, user_id)
|
||||
assert not any(name.startswith("mcp_mock-mcp_") for name in remaining_secret_names), (
|
||||
f"mock-mcp secrets should be deleted on remove; remaining secrets were "
|
||||
f"{sorted(remaining_secret_names)}"
|
||||
)
|
||||
@@ -48,7 +48,13 @@ mod tests {
|
||||
let store = rig.database();
|
||||
assert!(
|
||||
store
|
||||
.ensure_conversation(foreign_thread_id, "gateway", "victim-user", None)
|
||||
.ensure_conversation(
|
||||
foreign_thread_id,
|
||||
"gateway",
|
||||
"victim-user",
|
||||
None,
|
||||
Some("gateway")
|
||||
)
|
||||
.await
|
||||
.expect("failed to create victim conversation"),
|
||||
"test setup failed: victim conversation was not created"
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user