From 5692fcd37de39a7876e7cb4b94acedb85b2ffd12 Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Wed, 25 Mar 2026 23:04:08 -0700 Subject: [PATCH] feat: admin secrets provisioning API + API documentation - Add PUT/GET/DELETE /api/admin/users/{id}/secrets/{name} endpoints for application backends to provision per-user secrets (AES-256-GCM encrypted) - Add secrets_store field to GatewayState with builder wiring - Create docs/USER_MANAGEMENT_API.md with full API spec covering users, secrets, tokens, profile, and usage endpoints - Update web gateway CLAUDE.md route table Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 214 ++++++++++++---------- docs/USER_MANAGEMENT_API.md | 147 ++++++++++++++- src/channels/web/CLAUDE.md | 28 +++ src/channels/web/handlers/mod.rs | 1 + src/channels/web/handlers/secrets.rs | 133 ++++++++++++++ src/channels/web/mod.rs | 11 ++ src/channels/web/server.rs | 15 +- src/channels/web/test_helpers.rs | 1 + src/channels/web/tests/multi_tenant.rs | 1 + src/channels/web/ws.rs | 1 + src/main.rs | 3 + tests/multi_tenant_integration.rs | 3 + tests/openai_compat_integration.rs | 2 + tests/support/gateway_workflow_harness.rs | 1 + tests/ws_gateway_integration.rs | 1 + 15 files changed, 459 insertions(+), 103 deletions(-) create mode 100644 src/channels/web/handlers/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 27c258c1..014d4a09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,7 +157,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2012,22 +2012,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "der_derive", - "flagset", "zeroize", ] -[[package]] -name = "der_derive" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "deranged" version = "0.5.8" @@ -2136,7 +2123,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2323,7 +2310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2442,12 +2429,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.9" @@ -2476,6 +2457,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3150,7 +3146,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3434,9 +3430,11 @@ dependencies = [ "libsql", "lru", "mime_guess", + "native-tls", "open", "pdf-extract", "pgvector", + "postgres-native-tls", "postgres-types", "pretty_assertions", "rand 0.8.5", @@ -3447,8 +3445,6 @@ dependencies = [ "rig-core", "rust_decimal", "rust_decimal_macros", - "rustls 0.23.37", - "rustls-native-certs 0.8.3", "rustyline", "secrecy", "secret-service", @@ -3466,7 +3462,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-postgres", - "tokio-postgres-rustls", "tokio-stream", "tokio-test", "tokio-tungstenite 0.26.2", @@ -3523,7 +3518,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4087,6 +4082,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4143,7 +4155,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4309,6 +4321,32 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -4321,6 +4359,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4745,6 +4795,18 @@ dependencies = [ "serde", ] +[[package]] +name = "postgres-native-tls" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac73153d92e4bde922bd6f1dfba7f1ab8132266c031153b55e20a1521cd36d49" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + [[package]] name = "postgres-protocol" version = "0.6.10" @@ -4929,7 +4991,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.37", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4966,9 +5028,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5481,7 +5543,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6163,7 +6225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6388,7 +6450,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6599,27 +6661,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio" version = "1.50.0" @@ -6659,6 +6700,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.16" @@ -6685,21 +6736,6 @@ dependencies = [ "whoami", ] -[[package]] -name = "tokio-postgres-rustls" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" -dependencies = [ - "const-oid", - "ring", - "rustls 0.23.37", - "tokio", - "tokio-postgres", - "tokio-rustls 0.26.4", - "x509-cert", -] - [[package]] name = "tokio-rustls" version = "0.24.1" @@ -7188,7 +7224,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7341,6 +7377,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -8038,7 +8080,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -8517,18 +8559,6 @@ dependencies = [ "tap", ] -[[package]] -name = "x509-cert" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" -dependencies = [ - "const-oid", - "der", - "spki", - "tls_codec", -] - [[package]] name = "xattr" version = "1.6.1" @@ -8714,20 +8744,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" diff --git a/docs/USER_MANAGEMENT_API.md b/docs/USER_MANAGEMENT_API.md index 189697e6..7f6e73af 100644 --- a/docs/USER_MANAGEMENT_API.md +++ b/docs/USER_MANAGEMENT_API.md @@ -1,6 +1,6 @@ # User Management API -DB-backed user management for multi-tenant IronClaw deployments. Covers admin user CRUD, self-service profile, API token management, and usage reporting. +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 @@ -208,6 +208,113 @@ Permanently delete a user and all associated data (tokens, jobs, conversations, --- +## 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 @@ -393,11 +500,29 @@ All error responses return a plain text body with the error message and the corr | `401` | Missing or invalid bearer token | | `403` | Authenticated but insufficient role (member accessing admin endpoint) | | `404` | Resource not found | -| `503` | Database not available | +| `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 @@ -420,7 +545,7 @@ All error responses return a plain text body with the error message and the corr | Column | Type (PG / libSQL) | Notes | |--------|--------------------|-------| | `id` | `UUID` / `TEXT` | Primary key | -| `user_id` | `TEXT NOT NULL` | FK to `users.id` (PG cascades; libSQL uses explicit cleanup) | +| `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 | @@ -428,3 +553,19 @@ All error responses return a plain text body with the error message and the corr | `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Nullable | | `created_at` | `TIMESTAMPTZ` / `TEXT` | | | `revoked_at` | `TIMESTAMPTZ` / `TEXT` | Nullable; set on revocation | + +### secrets + +| Column | Type (PG / libSQL) | Notes | +|--------|--------------------|-------| +| `id` | `UUID` / `TEXT` | Primary key | +| `user_id` | `TEXT NOT NULL` | Scoped to user | +| `name` | `TEXT NOT NULL` | Unique per user (lowercase normalized) | +| `encrypted_value` | `BYTEA` / `BLOB` | AES-256-GCM (nonce + ciphertext + tag) | +| `key_salt` | `BYTEA` / `BLOB` | Per-secret HKDF salt | +| `provider` | `TEXT` | Optional grouping tag | +| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable | +| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Audit: last injection time | +| `usage_count` | `BIGINT` / `INTEGER` | Audit: total injections | +| `created_at` | `TIMESTAMPTZ` / `TEXT` | | +| `updated_at` | `TIMESTAMPTZ` / `TEXT` | | diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index 8db9a6b7..17af9cd1 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -91,6 +91,34 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl | DELETE | `/api/routines/{id}` | Delete a routine | | GET | `/api/routines/{id}/runs` | List runs for a specific routine | +### User Management (admin — requires `admin` role, see `docs/USER_MANAGEMENT_API.md`) +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/admin/users` | Create a new user (returns one-time token) | +| GET | `/api/admin/users` | List all users | +| GET | `/api/admin/users/{id}` | Get a single user | +| PATCH | `/api/admin/users/{id}` | Update user profile/metadata | +| DELETE | `/api/admin/users/{id}` | Delete user and all data | +| POST | `/api/admin/users/{id}/suspend` | Suspend a user | +| POST | `/api/admin/users/{id}/activate` | Re-activate a user | +| GET | `/api/admin/usage` | Per-user LLM usage stats | +| GET | `/api/admin/users/{id}/secrets` | List a user's secrets (names only) | +| PUT | `/api/admin/users/{id}/secrets/{name}` | Create or update a user's secret | +| DELETE | `/api/admin/users/{id}/secrets/{name}` | Delete a user's secret | + +### Profile (self-service) +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/profile` | Get own profile | +| PATCH | `/api/profile` | Update own display name/metadata | + +### Tokens (self-service) +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/tokens` | Create API token (returns plaintext once) | +| GET | `/api/tokens` | List own tokens | +| DELETE | `/api/tokens/{id}` | Revoke a token | + ### Settings | Method | Path | Description | |--------|------|-------------| diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs index 016cf6f7..b8958527 100644 --- a/src/channels/web/handlers/mod.rs +++ b/src/channels/web/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod jobs; pub mod memory; pub mod routines; +pub mod secrets; pub mod skills; pub mod tokens; pub mod users; diff --git a/src/channels/web/handlers/secrets.rs b/src/channels/web/handlers/secrets.rs new file mode 100644 index 00000000..edb33f94 --- /dev/null +++ b/src/channels/web/handlers/secrets.rs @@ -0,0 +1,133 @@ +//! Admin secrets provisioning handlers. +//! +//! Allows an admin (typically an application backend) to create, list, and +//! delete secrets on behalf of individual users so their IronClaw agent can +//! call back to external services with per-user credentials. + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; + +use crate::channels::web::auth::AdminUser; +use crate::channels::web::server::GatewayState; +use crate::secrets::CreateSecretParams; + +/// PUT /api/admin/users/{user_id}/secrets/{name} — create or update a secret. +/// +/// Upserts: if a secret with the same (user_id, name) already exists it is +/// overwritten. The plaintext value is encrypted at rest (AES-256-GCM) and +/// never returned by any endpoint. +pub async fn secrets_put_handler( + State(state): State>, + AdminUser(_admin): AdminUser, + Path((user_id, name)): Path<(String, String)>, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let 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_at = body + .get("expires_in_days") + .and_then(|v| v.as_u64()) + .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); + } + + 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.to_lowercase(), + "status": "created", + }))) +} + +/// GET /api/admin/users/{user_id}/secrets — list a user's secrets (names only). +/// +/// Never returns secret values or hashes. +pub async fn secrets_list_handler( + State(state): State>, + AdminUser(_admin): AdminUser, + Path(user_id): Path, +) -> Result, (StatusCode, String)> { + let secrets = state.secrets_store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Secrets store not available".to_string(), + ))?; + + let refs = secrets + .list(&user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let secrets_json: Vec = refs + .into_iter() + .map(|r| { + serde_json::json!({ + "name": r.name, + "provider": r.provider, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "user_id": user_id, + "secrets": secrets_json, + }))) +} + +/// DELETE /api/admin/users/{user_id}/secrets/{name} — delete a user's secret. +pub async fn secrets_delete_handler( + State(state): State>, + AdminUser(_admin): AdminUser, + Path((user_id, name)): Path<(String, String)>, +) -> Result, (StatusCode, String)> { + let secrets = state.secrets_store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Secrets store not available".to_string(), + ))?; + + let deleted = secrets + .delete(&user_id, &name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !deleted { + return Err((StatusCode::NOT_FOUND, "Secret not found".to_string())); + } + + Ok(Json(serde_json::json!({ + "user_id": user_id, + "name": name, + "deleted": true, + }))) +} diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index dad43a32..e267825a 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -116,6 +116,7 @@ impl GatewayChannel { routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), active_config: server::ActiveConfigSnapshot::default(), + secrets_store: None, }); Self { @@ -169,6 +170,7 @@ 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(), }; mutate(&mut new_state); self.state = Arc::new(new_state); @@ -292,6 +294,15 @@ impl GatewayChannel { self } + /// Inject the secrets store for admin secret provisioning. + pub fn with_secrets_store( + mut self, + store: Arc, + ) -> Self { + self.rebuild_state(|s| s.secrets_store = Some(store)); + self + } + /// Inject the per-user workspace pool for multi-user mode. pub fn with_workspace_pool(mut self, pool: Arc) -> Self { self.rebuild_state(|s| s.workspace_pool = Some(pool)); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 16f348cd..857aa246 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -16,7 +16,7 @@ use axum::{ IntoResponse, sse::{Event, KeepAlive, Sse}, }, - routing::{get, post}, + routing::{get, post, put}, }; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -378,6 +378,8 @@ 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>, } /// Start the gateway HTTP server. @@ -532,6 +534,16 @@ pub async fn start_server( "/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", @@ -3041,6 +3053,7 @@ mod tests { routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), active_config: ActiveConfigSnapshot::default(), + secrets_store: None, }) } diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs index accab9dc..00c02467 100644 --- a/src/channels/web/test_helpers.rs +++ b/src/channels/web/test_helpers.rs @@ -92,6 +92,7 @@ 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, }) } diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index f92073a4..470dd2cf 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -84,6 +84,7 @@ 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, }) } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 51beaafd..5e10a9d8 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -535,6 +535,7 @@ 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, } } } diff --git a/src/main.rs b/src/main.rs index b351a2ae..8224d507 100644 --- a/src/main.rs +++ b/src/main.rs @@ -632,6 +632,9 @@ 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. diff --git a/tests/multi_tenant_integration.rs b/tests/multi_tenant_integration.rs index 08ffa28d..f4ce7353 100644 --- a/tests/multi_tenant_integration.rs +++ b/tests/multi_tenant_integration.rs @@ -563,6 +563,7 @@ 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, }; assert_eq!(state.owner_id, "fallback"); @@ -639,6 +640,7 @@ 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, }); let auth = MultiAuthState::multi(tokens).into(); @@ -1024,6 +1026,7 @@ 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, }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index d82be271..b7aed757 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -219,6 +219,7 @@ 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, }); let auth = ironclaw::channels::web::auth::MultiAuthState::single( @@ -718,6 +719,7 @@ 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, }); let auth = ironclaw::channels::web::auth::MultiAuthState::single( diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index 08104d6c..d6d02cd5 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -241,6 +241,7 @@ impl GatewayWorkflowHarness { routine_engine: Arc::clone(&routine_slot), startup_time: Instant::now(), active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), + secrets_store: None, }); let mut agent = Agent::new( diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index 2f3f9bfc..4fc6cd8f 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -66,6 +66,7 @@ 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, }); let auth = ironclaw::channels::web::auth::MultiAuthState::single(