Files
ironclaw/docs/internal/USER_MANAGEMENT_API.md
Illia Polosukhin 95dcf807e0 fix(gateway): serve Responses API under /api/v1/ prefix (#2201) (#2748)
* fix(gateway): serve Responses API under /api/v1/ prefix (#2201)

The OpenAI Responses API was only reachable at `/v1/responses`, which
broke the otherwise consistent `/api/...` prefix used by every other
IronClaw HTTP surface. Callers expecting `/api/v1/responses` got a 404.

This routes both paths through the same handlers:

- `/api/v1/responses` + `/api/v1/responses/{id}` — canonical paths
- `/v1/responses` + `/v1/responses/{id}` — retained as backward-compat
  aliases for clients already configured against the legacy path

Also updates the web gateway CLAUDE.md route table, the
USER_MANAGEMENT_API.md reference, and the module docstring for
responses_api.rs so documentation points at the canonical prefix.

Regression test: tests/responses_api_path_prefix.rs drives the full
router via `start_server` and asserts that POST/GET on both the
canonical and legacy paths reach the handler (400 from the handler
for bad inputs, not 404 from the router) and that both paths enforce
bearer auth (401 without a token). This follows the "Test Through the
Caller, Not Just the Helper" rule so a future router edit that drops
either path fails the test rather than silently regressing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gateway): address PR #2748 review feedback

- Extend both_paths_require_auth to cover GET /responses/{id} on both
  canonical and legacy paths.
- Align USER_MANAGEMENT_API.md Responses API examples with the current
  handler behavior (only "default" model accepted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: address PR #2748 reviewer nits

- Change the "Go ahead with the transfer" Responses API request example
  to use "model": "default". The handler rejects any other value, so
  copying the old example verbatim would 400.
- Expand the Error Format section to document that the Responses API
  returns an OpenAI-compatible JSON envelope ({"error": {...}}) rather
  than the plain-text body used by every other endpoint. Add 429 to the
  status-code table for Responses API rate limiting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR #2748 Copilot review nits on docs + test cleanup

- Correct the documented Responses API 429 error type from
  `rate_limit_exceeded` to `rate_limit_error` to match what
  `create_response_handler` actually emits.
- Clarify that the JSON error envelope covers handler-generated
  errors; missing/invalid bearer token (401) and auth-path 503
  are returned by the shared gateway auth middleware as plain text.
- Add a `ServerGuard` RAII helper in the Responses API path-prefix
  integration test that takes `state.shutdown_tx` on startup and
  sends `()` on drop, so each test tears its `axum::serve` task
  down instead of leaking it for the rest of the process. Update
  the six test callers to bind the guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:00:19 +09:00

19 KiB

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:

{
  "display_name": "Alice Smith",
  "email": "alice@example.com",
  "role": "member"
}
Field Type Required Default Notes
display_name string yes
email string no null Must be unique if provided
role string no "member" "admin" or "member"

Response: 200 OK

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "alice@example.com",
  "display_name": "Alice Smith",
  "status": "active",
  "role": "member",
  "token": "a1b2c3d4e5f6...64-char hex...",
  "created_at": "2026-03-25T12:00:00+00:00",
  "created_by": "admin-user-id"
}

The token field is the plaintext API token. It is shown only once — store it securely.

Errors: 400 (missing display_name, invalid role), 403 (not admin), 503 (no database)


GET /api/admin/users

List all users.

Auth: Admin

Response: 200 OK

{
  "users": [
    {
      "id": "550e8400-...",
      "email": "alice@example.com",
      "display_name": "Alice Smith",
      "status": "active",
      "role": "member",
      "created_at": "2026-03-25T12:00:00+00:00",
      "updated_at": "2026-03-25T12:00:00+00:00",
      "last_login_at": "2026-03-25T14:30:00+00:00",
      "created_by": "admin-user-id"
    }
  ]
}

GET /api/admin/users/{id}

Get a single user by ID.

Auth: Admin

Response: 200 OK

{
  "id": "550e8400-...",
  "email": "alice@example.com",
  "display_name": "Alice Smith",
  "status": "active",
  "role": "member",
  "created_at": "2026-03-25T12:00:00+00:00",
  "updated_at": "2026-03-25T12:00:00+00:00",
  "last_login_at": "2026-03-25T14:30:00+00:00",
  "created_by": "admin-user-id",
  "metadata": {}
}

Errors: 404 (user not found), 403 (not admin)


PATCH /api/admin/users/{id}

Update a user's display name and/or metadata. Omitted fields are left unchanged.

Auth: Admin

Request body:

{
  "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

{
  "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

{
  "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

{
  "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:

{
  "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

{
  "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:

# 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

{
  "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

{
  "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

{
  "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

{
  "id": "550e8400-...",
  "email": "alice@example.com",
  "display_name": "Alice Smith",
  "status": "active",
  "role": "member",
  "created_at": "2026-03-25T12:00:00+00:00",
  "last_login_at": "2026-03-25T14:30:00+00:00"
}

PATCH /api/profile

Update the authenticated user's own display name and/or metadata.

Auth: Any authenticated user

Request body:

{
  "display_name": "Alice Johnson",
  "metadata": {"theme": "dark"}
}

Response: 200 OK

{
  "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:

{
  "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

{
  "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

{
  "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

{
  "status": "revoked",
  "id": "token-uuid"
}

Errors: 400 (invalid UUID), 404 (token not found or belongs to another user)


Responses API

Routes through the full agent loop — tools, memory, safety, and server-side conversation state are all active. Compatible with any standard OpenAI SDK via client.responses.create().

Path prefix: The canonical path is /api/v1/responses, matching the rest of IronClaw's HTTP surface. The older /v1/responses path is retained as an alias for backward compatibility with clients that were already configured against it (see ironclaw#2201). Both paths accept identical request bodies and return identical responses; new integrations should target /api/v1/responses.

Auth: Any authenticated user (Authorization: Bearer <token>)

POST /api/v1/responses

Alias: POST /v1/responses (legacy; kept for backward compatibility).

Create a response.

Request body:

{
  "model": "default",
  "input": "What's a good time to send money to India?",
  "stream": true,
  "previous_response_id": null
}
Field Type Notes
model string Currently only "default" is accepted; the server-selected model is determined by gateway configuration. Any other value returns 400.
input string or array User message as a string, or a messages array (see below)
stream boolean true for SSE, false for blocking (120s timeout)
previous_response_id string Pass the previous id to continue a conversation thread

Messages array input: input may be passed as an array instead of a string:

{
  "model": "default",
  "input": [
    { "role": "user", "content": "What is 2+2? Reply with just the number." }
  ]
}

Response (non-streaming): 200 OK

{
  "id": "resp_<uuid>",
  "object": "response",
  "created_at": 1743000000,
  "model": "claude-sonnet-4-5-20250514",
  "status": "completed",
  "output": [
    {
      "type": "message",
      "id": "msg_<uuid>",
      "role": "assistant",
      "content": [{ "type": "output_text", "text": "Based on today's rate..." }]
    }
  ],
  "usage": {
    "input_tokens": 320,
    "output_tokens": 95,
    "total_tokens": 415
  }
}

Streaming: When stream: true, IronClaw returns SSE events:

Event Description
response.created Response object created, stream begins
response.output_item.added New output item started (message or tool call)
response.output_text.delta Text chunk
response.output_item.done Output item finalized
response.completed Full response done
response.failed Error or tool approval required

Multi-turn: Pass previous_response_id from the previous response's id to continue in the same thread. IronClaw decodes the thread UUID statelessly from the response ID — no lookup table required.

Status values: completed | failed


Structured context

The input field accepts a structured context object alongside the user message. Use this to pass machine-readable state that the agent should act on, without relying solely on natural language.

Request:

{
  "model": "default",
  "input": "Go ahead with the transfer",
  "previous_response_id": "resp_...",
  "x_context": {
    "notification_response": {
      "notification_id": "msg_123",
      "action": "approved",
      "original_signal": "convert_now",
      "score": 72,
      "rate": 85.42,
      "amount_usd": 1000
    }
  }
}

The agent receives the context prepended to the user message:

[Context: notification_response — notification_id: msg_123, action: approved,
original_signal: convert_now, score: 72, rate: 85.42, amount_usd: 1000]
Go ahead with the transfer

The context payload is persisted in message metadata for auditability.


GET /api/v1/responses/{id}

Alias: GET /v1/responses/{id} (legacy; kept for backward compatibility).

Retrieve a historical response reconstructed from conversation messages in the database. Users can only retrieve their own responses.

Auth: Any authenticated user

Response: 200 OK — same shape as the create response object above.

Errors: 400 (empty input), 401 (missing or invalid bearer token), 404 (response ID not found or not owned by caller), 500 (internal error)


Error Format

Most endpoints (admin, profile, tokens, settings, memory, jobs, routines, skills, extensions) return a plain text body with the error message and the corresponding HTTP status code.

The Responses API (POST /api/v1/responses, GET /api/v1/responses/{id}, and their legacy /v1/... aliases) returns an OpenAI-compatible JSON envelope for errors generated by the handler itself:

{
  "error": {
    "message": "human-readable description",
    "type": "invalid_request_error"
  }
}

An optional code string field may appear when the server can attach a machine-readable subtype; it is omitted when absent. Error type values used by the current handler: invalid_request_error (400), rate_limit_error (429), server_error (500), service_unavailable (503). Clients should be tolerant of additional OpenAI-conventional types appearing in the future.

Middleware exception: requests rejected before the handler runs — missing or invalid bearer token (401), service-unavailable during auth setup (503) — are emitted by the shared gateway auth middleware as plain-text bodies, not JSON envelopes. Clients should therefore treat non-JSON 401/503 responses as expected and handler-level failures as JSON.

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
429 Rate limit exceeded (Responses API)
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