Full rename of all identifiers, filenames, and references: ironclaw → optimclaw IronClaw → OptimClaw IRONCLAW → OPTIMCLAW ironclaw_common → optimclaw_common ironclaw_safety → optimclaw_safety Upstream: nearai/ironclaw
14 KiB
User Management API
DB-backed user management for multi-tenant OptimClaw 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/tokensorPOST /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": "[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
{
"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
{
"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
{
"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:
{
"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 OptimClaw 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://optimclaw.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://optimclaw.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 OptimClaw 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": "[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:
{
"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)
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_KEYenv var or OS keychain - Storage format:
nonce (12B) || ciphertext || tag (16B)inencrypted_valuecolumn - 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 |