From bce38c647e4ec4d4d6b79bfb6a2603fe375b0517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Mon, 27 Jul 2026 09:22:10 +0200 Subject: [PATCH 1/2] feat: dual JWT/per-user API key auth, RLS per user_id, per-user LLM credentials, store namespaces per user - Dual auth: JWT (Authorization: Bearer, validated via Logto OIDC JWKS, replicated from pickpro-back JwtAdapter) OR per-user API keys (X-API-Key, SHA-256 hashed, user_id-scoped). New env vars LOGTO_URL, JWT_AUDIENCE. - Per-user API keys: new endpoints POST/GET/DELETE /api/v1/api-keys (create returns cpk_... plaintext once, list masks hash, revoke idempotent). - RLS per user_id on agent_configs, threads, trace_events, api_keys, user_llm_settings: SQLAlchemy listener sets GUC app.user_id per request from the contextvar set by verify_credentials. Migrations 012-014. - Per-user LLM credentials: new endpoints GET/PUT/DELETE /api/v1/settings/llm. Credentials Fernet-encrypted at rest (SECRET_ENRYPTION_KEY). Agent factory resolves credentials per request from the authenticated user; OPENAI_API_KEY no longer used. LlmNotConfiguredError (422) if not configured. - LangGraph Store namespaces per user: skills/memories (Store File API) and agent namespace copies isolated via (user_id, 'filesystem') prefix. Isolation enforced at application layer (LangGraph store uses its own asyncpg pool, not the SQLAlchemy engine that sets the RLS GUC). - MCP credential propagation: agent YAML mcp_servers headers can use ${USER_JWT} and ${USER_API_KEY} placeholders resolved from the authenticated caller's credential; empty resolved headers dropped. Tests: 667 unit tests pass (+152 new covering auth, api keys, RLS, LLM, namespaces, MCP propagation). QA: 98 tests pass on the local Docker stack with auto-seeded QA API keys (composable-agents-qa-init one-shot service). --- README.md | 609 ++++++++++++++++-- .../single/haiku-files-local-structured.yaml | 3 +- agents/single/haiku-files-local.yaml | 3 +- agents/single/haiku-rag-formation.yaml | 3 + agents/single/haiku-rag-local.yaml | 3 +- agents/single/haiku-rag.yaml | 3 +- src/alembic/env.py | 2 + .../versions/011_create_api_keys_table.py | 51 ++ .../versions/012_add_user_id_to_rls_tables.py | 54 ++ .../versions/013_enable_rls_policies.py | 64 ++ .../014_create_user_llm_settings_table.py | 64 ++ src/application/requests/api_key.py | 13 + src/application/requests/user_llm_settings.py | 15 + src/application/routes/api_keys.py | 89 +++ src/application/routes/user_llm_settings.py | 86 +++ src/application/routes/websocket.py | 18 +- .../use_cases/_subagent_ref_utils.py | 4 +- .../use_cases/api_key/create_api_key.py | 63 ++ .../use_cases/api_key/list_api_keys.py | 36 ++ .../use_cases/api_key/revoke_api_key.py | 37 ++ src/application/use_cases/get_agent_config.py | 29 +- .../delete_user_llm_settings.py | 26 + .../get_user_llm_settings.py | 29 + .../resolve_user_llm_credentials.py | 33 + .../upsert_user_llm_settings.py | 44 ++ src/config.py | 4 + src/dependencies.py | 219 ++++++- src/domain/entities/agent_config_metadata.py | 4 + src/domain/entities/auth/api_key.py | 54 ++ src/domain/entities/auth/auth_context.py | 27 + src/domain/entities/thread.py | 5 + src/domain/entities/trace_event.py | 4 + src/domain/entities/user/user.py | 27 + src/domain/entities/user_llm_settings.py | 42 ++ src/domain/errors/llm.py | 24 + src/domain/errors/messages.py | 12 + src/domain/errors/security.py | 27 + src/domain/logging/messages.py | 26 + src/domain/ports/auth/api_key_repository.py | 90 +++ src/domain/ports/auth/jwt_service.py | 33 + .../ports/user_llm_settings_repository.py | 73 +++ src/domain/services/auth/api_key_hasher.py | 39 ++ src/domain/services/auth/auth_service.py | 95 +++ src/infrastructure/auth/api_key_hasher.py | 10 + src/infrastructure/auth/jwt_adapter.py | 183 ++++++ src/infrastructure/crypto/fernet_crypto.py | 57 ++ .../database/models/agent_config.py | 5 +- src/infrastructure/database/models/api_key.py | 44 ++ src/infrastructure/database/models/thread.py | 6 +- .../database/models/trace_event.py | 6 +- .../database/models/user_llm_setting.py | 35 + src/infrastructure/database/rls_context.py | 59 ++ src/infrastructure/database/rls_listener.py | 88 +++ src/infrastructure/deepagent/factory.py | 274 ++++++-- src/infrastructure/deepagent/namespace.py | 39 ++ src/infrastructure/env_utils.py | 134 +++- src/infrastructure/mcp/adapter.py | 22 +- .../persistent_registry/adapter.py | 9 +- .../postgres_api_key/adapter.py | 197 ++++++ .../postgres_repository/adapter.py | 78 ++- src/infrastructure/postgres_thread/adapter.py | 70 +- src/infrastructure/postgres_trace/adapter.py | 49 +- .../postgres_user_llm/adapter.py | 198 ++++++ src/infrastructure/store_file/adapter.py | 67 +- src/main.py | 28 +- src/security.py | 120 +++- tests/conftest.py | 5 + .../unit/test_agent_config_user_isolation.py | 177 +++++ tests/unit/test_agent_crud.py | 68 +- tests/unit/test_api_key_hasher.py | 89 +++ tests/unit/test_api_key_repository.py | 243 +++++++ tests/unit/test_api_key_routes.py | 245 +++++++ tests/unit/test_api_key_use_cases.py | 169 +++++ tests/unit/test_auth_service.py | 171 +++++ tests/unit/test_env_utils_user_credentials.py | 191 ++++++ tests/unit/test_factory_llm_per_user.py | 117 ++++ tests/unit/test_fernet_crypto.py | 78 +++ tests/unit/test_jwt_adapter.py | 343 ++++++++++ .../unit/test_mcp_adapter_user_credentials.py | 179 +++++ tests/unit/test_namespace.py | 81 +++ tests/unit/test_postgres_repository.py | 4 +- ..._prepare_agent_namespace_user_isolation.py | 117 ++++ tests/unit/test_rls_context.py | 88 +++ tests/unit/test_rls_listener.py | 180 ++++++ tests/unit/test_routes.py | 34 +- tests/unit/test_store_file_user_isolation.py | 186 ++++++ tests/unit/test_store_routes.py | 14 +- tests/unit/test_thread_user_isolation.py | 158 +++++ tests/unit/test_trace_event_user_isolation.py | 101 +++ .../unit/test_user_llm_settings_repository.py | 192 ++++++ tests/unit/test_user_llm_settings_routes.py | 186 ++++++ .../unit/test_user_llm_settings_use_cases.py | 156 +++++ tests/unit/test_verify_credentials.py | 189 ++++++ .../test_verify_credentials_sets_method.py | 96 +++ tests/unit/test_verify_credentials_wiring.py | 163 +++++ tests/unit/test_websocket_auth.py | 126 ++++ 96 files changed, 7906 insertions(+), 204 deletions(-) create mode 100644 src/alembic/versions/011_create_api_keys_table.py create mode 100644 src/alembic/versions/012_add_user_id_to_rls_tables.py create mode 100644 src/alembic/versions/013_enable_rls_policies.py create mode 100644 src/alembic/versions/014_create_user_llm_settings_table.py create mode 100644 src/application/requests/api_key.py create mode 100644 src/application/requests/user_llm_settings.py create mode 100644 src/application/routes/api_keys.py create mode 100644 src/application/routes/user_llm_settings.py create mode 100644 src/application/use_cases/api_key/create_api_key.py create mode 100644 src/application/use_cases/api_key/list_api_keys.py create mode 100644 src/application/use_cases/api_key/revoke_api_key.py create mode 100644 src/application/use_cases/user_llm_settings/delete_user_llm_settings.py create mode 100644 src/application/use_cases/user_llm_settings/get_user_llm_settings.py create mode 100644 src/application/use_cases/user_llm_settings/resolve_user_llm_credentials.py create mode 100644 src/application/use_cases/user_llm_settings/upsert_user_llm_settings.py create mode 100644 src/domain/entities/auth/api_key.py create mode 100644 src/domain/entities/auth/auth_context.py create mode 100644 src/domain/entities/user/user.py create mode 100644 src/domain/entities/user_llm_settings.py create mode 100644 src/domain/errors/llm.py create mode 100644 src/domain/ports/auth/api_key_repository.py create mode 100644 src/domain/ports/auth/jwt_service.py create mode 100644 src/domain/ports/user_llm_settings_repository.py create mode 100644 src/domain/services/auth/api_key_hasher.py create mode 100644 src/domain/services/auth/auth_service.py create mode 100644 src/infrastructure/auth/api_key_hasher.py create mode 100644 src/infrastructure/auth/jwt_adapter.py create mode 100644 src/infrastructure/crypto/fernet_crypto.py create mode 100644 src/infrastructure/database/models/api_key.py create mode 100644 src/infrastructure/database/models/user_llm_setting.py create mode 100644 src/infrastructure/database/rls_context.py create mode 100644 src/infrastructure/database/rls_listener.py create mode 100644 src/infrastructure/deepagent/namespace.py create mode 100644 src/infrastructure/postgres_api_key/adapter.py create mode 100644 src/infrastructure/postgres_user_llm/adapter.py create mode 100644 tests/unit/test_agent_config_user_isolation.py create mode 100644 tests/unit/test_api_key_hasher.py create mode 100644 tests/unit/test_api_key_repository.py create mode 100644 tests/unit/test_api_key_routes.py create mode 100644 tests/unit/test_api_key_use_cases.py create mode 100644 tests/unit/test_auth_service.py create mode 100644 tests/unit/test_env_utils_user_credentials.py create mode 100644 tests/unit/test_factory_llm_per_user.py create mode 100644 tests/unit/test_fernet_crypto.py create mode 100644 tests/unit/test_jwt_adapter.py create mode 100644 tests/unit/test_mcp_adapter_user_credentials.py create mode 100644 tests/unit/test_namespace.py create mode 100644 tests/unit/test_prepare_agent_namespace_user_isolation.py create mode 100644 tests/unit/test_rls_context.py create mode 100644 tests/unit/test_rls_listener.py create mode 100644 tests/unit/test_store_file_user_isolation.py create mode 100644 tests/unit/test_thread_user_isolation.py create mode 100644 tests/unit/test_trace_event_user_isolation.py create mode 100644 tests/unit/test_user_llm_settings_repository.py create mode 100644 tests/unit/test_user_llm_settings_routes.py create mode 100644 tests/unit/test_user_llm_settings_use_cases.py create mode 100644 tests/unit/test_verify_credentials.py create mode 100644 tests/unit/test_verify_credentials_sets_method.py create mode 100644 tests/unit/test_verify_credentials_wiring.py create mode 100644 tests/unit/test_websocket_auth.py diff --git a/README.md b/README.md index 50f4483..ee7c43e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,74 @@ Configure Deep Agent LangGraph agents in YAML and expose them via FastAPI. The server supports **multi-agent mode**: multiple agents are defined as separate YAML files in an `agents/` directory, each thread is bound to a specific agent at creation time, and agents are lazily instantiated on first use. Sub-agents declared in the `subagents` config are fully traced: every event emitted by a sub-agent carries its name in the `source` field of the corresponding `TraceEvent`, so the client can group events per sub-agent (see [TraceEvent](#traceevent-format)). +The server enforces **per-user isolation** via **dual authentication** (JWT via Logto OIDC **or** per-user API keys), **PostgreSQL Row-Level Security** (RLS) on every per-user table, **per-user LLM credentials** (each user brings their own provider/key), and **per-user LangGraph Store namespaces** for skills and memories. See [Authentication](#authentication), [Row-Level Security (RLS)](#row-level-security-rls), [Per-User LLM Settings](#per-user-llm-settings), and [Store Namespaces per User](#store-namespaces-per-user). + +--- + +## Authentication + +composable-agents uses **dual authentication**: every protected endpoint accepts **either** a JWT bearer token (validated against the Logto OIDC JWKS) **or** a per-user API key. The chosen credential determines the authenticated `user_id` that is propagated to PostgreSQL Row-Level Security and to the per-user LLM credential resolver. + +### Methods + +| Method | Header | `user_id` source | Validated by | +|---|---|---|---| +| **JWT** | `Authorization: Bearer ` | the JWT `sub` claim | Logto OIDC JWKS (`LOGTO_URL` + `JWT_AUDIENCE`) | +| **API key** | `X-API-Key: cpk_...` | the `user_id` column on the matching `api_keys` row | SHA-256 hash lookup in the `api_keys` table | + +The two methods are mutually exclusive on a given request — send **one** of the two headers. If neither validates, the server returns `401 {"detail": "Invalid or missing credentials"}`. + +> **Deprecated for auth:** The old single master `OPENAI_API_KEY` / `API_KEY` settings are no longer used to authenticate requests. `OPENAI_API_KEY` is also no longer used to call the LLM — each user configures their own provider via [Per-User LLM Settings](#per-user-llm-settings). + +### Obtaining a JWT (production) + +In production, the user authenticates against **Logto** (via an oauth2-proxy in front of composable-agents) and receives a JWT. The `Authorization: Bearer ` header is then forwarded to composable-agents, which validates the signature against the Logto JWKS and the `aud` claim against `JWT_AUDIENCE`. The JWT `sub` becomes the `user_id` for RLS and per-user LLM resolution. + +### Obtaining a per-user API key + +A user first authenticates with a JWT, then creates an API key they can reuse for script/automation use: + +```bash +# Requires a JWT first (the user must be logged in via Logto). +curl -X POST http://localhost:8000/api/v1/api-keys \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "my-ci-key"}' +# → 201 Created +# { +# "id": "8f3c...", +# "name": "my-ci-key", +# "key_prefix": "cpk_abcde", +# "plaintext": "cpk_abcdefghijklmnopqrstuvwxyz0123456789...", # shown ONCE +# "created_at": "2026-07-27T10:00:00Z" +# } +``` + +The plaintext is returned **exactly once**; only its SHA-256 hash is persisted in `api_keys` (column `key_hash`). Store the plaintext securely — it cannot be recovered. The `cpk_...` prefix identifies composable-agents keys. + +### QA / local mode (no Logto) + +In QA and local dev, there is no Logto instance. The QA stack seeds two API keys directly in the `api_keys` table via the `composable-agents-qa-init` one-shot service (see [Testing](#testing)). The two seed keys are: + +| `user_id` | plaintext `X-API-Key` | +|---|---| +| `qa-user-1` | `cpk_qa_test_key_12345` | +| `qa-user-2` | `cpk_qa_test_key_67890` | + +When `LOGTO_URL` is empty, the JWT path is disabled and only the per-user API key path is active. + +### Authenticated request examples + +```bash +# JWT +curl http://localhost:8000/api/v1/agents \ + -H "Authorization: Bearer " + +# Per-user API key +curl http://localhost:8000/api/v1/agents \ + -H "X-API-Key: cpk_..." +``` + --- ## Quickstart (5 minutes) @@ -14,8 +82,9 @@ The server supports **multi-agent mode**: multiple agents are defined as separat - Python 3.11+ - [UV](https://docs.astral.sh/uv/) package manager -- PostgreSQL 15+ (required for thread and agent config persistence) -- An API key for at least one LLM provider (Anthropic, OpenAI, or Google) +- PostgreSQL 15+ (required for thread, agent config, API key, and LLM-settings persistence, with Row-Level Security enabled) +- A **Logto** instance (OIDC issuer) for JWT validation in production — optional in QA/local (see [Authentication](#authentication)) +- Each end-user configures their own LLM provider credentials via [Per-User LLM Settings](#per-user-llm-settings); there is no longer a server-wide LLM key ### Installation @@ -26,17 +95,27 @@ uv sync cp .env.example .env ``` -Edit `.env` and add your API key and database credentials: +Edit `.env` and add your database and dual-auth credentials: ```dotenv -OPENAI_API_KEY=sk-... - # PostgreSQL (required) DATABASE_URL=postgresql://raganything:raganything@localhost:5433/raganything + +# Dual auth (JWT via Logto OIDC + per-user API keys) +# Leave LOGTO_URL empty in local QA (only X-API-Key auth is used). +# In prod, set both to enable JWT validation. +LOGTO_URL= +JWT_AUDIENCE= + +# Fernet key used to encrypt per-user LLM API keys at rest. +# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +SECRET_ENCRYPTION_KEY= ``` > **⚠️ Breaking change:** The `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DATABASE` environment variables have been replaced by a single `DATABASE_URL` variable. If you are upgrading from a previous version, construct your `DATABASE_URL` as `postgresql://:@:/` and remove the old `POSTGRES_*` variables from your `.env`. +> **⚠️ Breaking change (auth):** The single master `X-API-Key` / `API_KEY` model has been replaced by **dual JWT + per-user API keys**. The `OPENAI_API_KEY` env var is no longer used to call the LLM — each user configures their own provider via `PUT /api/v1/settings/llm`. See [Authentication](#authentication) and [Per-User LLM Settings](#per-user-llm-settings). + > **⚠️ Breaking change (trace events):** The legacy `StreamEvent` SSE format and the `messages` table have been removed. The `/stream` and WebSocket endpoints now emit `TraceEvent` JSON objects. See [Breaking Changes](#breaking-changes) for migration details. ### Configure your agents @@ -71,30 +150,37 @@ Agents are not loaded into memory until a thread references them for the first t ### Test with curl +> Replace `` with a per-user API key (e.g. `cpk_qa_test_key_12345` in QA) or use `-H "Authorization: Bearer "`. See [Authentication](#authentication). + ```bash -# Health check +# Health check (public, no auth) curl http://localhost:8000/health # Create a thread bound to an agent (agent_name must match a YAML filename in agents/) curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"agent_name": "my-agent"}' # Send a message (replace with the id from the previous response) curl -X POST http://localhost:8000/api/v1/chat/ \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"message": "Hello, what can you do?"}' # Stream a message (yields TraceEvent JSON objects, one per SSE line, ends with [DONE]) curl -N -X POST http://localhost:8000/api/v1/chat//stream \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"message": "Hello, what can you do?"}' # Get the full thread history (thread + turns grouped by turn_id) -curl http://localhost:8000/api/v1/threads//history +curl http://localhost:8000/api/v1/threads//history \ + -H "X-API-Key: " # Get the flat trace of events for a thread -curl http://localhost:8000/api/v1/threads//trace +curl http://localhost:8000/api/v1/threads//trace \ + -H "X-API-Key: " ``` --- @@ -116,30 +202,36 @@ composable-agents now supports running **multiple agents simultaneously**. Each |---|---|---| | `AgentRegistry` (port) | `src/domain/ports/agent_registry.py` | Abstract interface for retrieving agent runners by name. | | `DeepAgentRegistry` (adapter) | `src/infrastructure/deepagent/registry.py` | Scans `agents/` directory, creates and caches runners on demand. | -| `AgentNotFoundError` | `src/domain/exceptions.py` | Raised when a requested agent name has no corresponding YAML file. | +| `AgentNotFoundError` | `src/domain/errors/agent.py` | Raised when a requested agent name has no corresponding YAML file. | ### Example: two agents, two threads +> All requests require `X-API-Key` or `Authorization: Bearer` (see [Authentication](#authentication)). + ```bash # Create a thread using the research assistant agent curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"agent_name": "research-assistant"}' # Returns: {"id": "thread-1-uuid", "agent_name": "research-assistant", ...} # Create another thread using the code reviewer agent curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"agent_name": "code-reviewer"}' # Returns: {"id": "thread-2-uuid", "agent_name": "code-reviewer", ...} # Each thread talks to its own agent curl -X POST http://localhost:8000/api/v1/chat/ \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"message": "Summarize the latest research on transformers."}' curl -X POST http://localhost:8000/api/v1/chat/ \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"message": "Review this Python function for security issues."}' ``` @@ -374,6 +466,33 @@ MCP_RAGANYTHING_API_KEY=your-shared-secret-key The value must match the `API_KEY` configured on the [mcp-raganything](https://github.com/soludev/mcp-raganything) server. +### Per-user credential propagation (`${USER_JWT}` / `${USER_API_KEY}`) + +In addition to env-var placeholders, MCP server `headers` support two **per-user** placeholders that are resolved from the authenticated caller's credential at agent-build time: + +| Placeholder | Resolved to | When | +|---|---|---| +| `${USER_JWT}` | the caller's raw JWT (without `Bearer ` prefix) | the request was authenticated with a JWT | +| `${USER_API_KEY}` | the caller's raw per-user API key (`cpk_...`) | the request was authenticated with an API key | + +This lets an agent forward the caller's identity to a downstream MCP server (e.g. raganything) without storing a shared secret, so the downstream server can apply its own per-user RLS: + +```yaml +mcp_servers: + - name: raganything + transport: http + url: https://raganything.soludev.tech/bricks/mcp + headers: + Authorization: "Bearer ${USER_JWT}" + X-API-Key: "${USER_API_KEY}" +``` + +Resolution rules: + +- The placeholder is filled with `current_credential` only when `current_auth_method` matches (`"jwt"` for `${USER_JWT}`, `"api_key"` for `${USER_API_KEY}`). +- If the method does not match (e.g. the caller used an API key but the header uses `${USER_JWT}`), or no credential is set, the resolved value is **empty**. +- **Empty resolved headers are dropped** from the outgoing MCP request, so the downstream server receives no spurious empty auth header. + --- ## Middlewares @@ -423,11 +542,19 @@ The `StoreBackend` is wired with a per-run namespace via `StoreBackend(store=sto All endpoints are prefixed appropriately. The server runs on `http://localhost:8000` by default. +> **Auth:** Every endpoint **except** `GET /health` requires either `Authorization: Bearer ` or `X-API-Key: cpk_...` (see [Authentication](#authentication)). The `X-API-Key` shown in the curl examples below is a placeholder — replace it with your per-user key. Each authenticated user only sees rows they own (see [Row-Level Security (RLS)](#row-level-security-rls)). + | Method | Path | Description | Success Status | |---|---|---|---| -| `GET` | `/health` | Health check | `200` | +| `GET` | `/health` | Health check (public, no auth) | `200` | +| `POST` | `/api/v1/api-keys` | Create a new per-user API key (returns plaintext once) | `201` | +| `GET` | `/api/v1/api-keys` | List the authenticated user's API keys (no plaintext) | `200` | +| `DELETE` | `/api/v1/api-keys/{key_id}` | Revoke a per-user API key (idempotent) | `204` | +| `GET` | `/api/v1/settings/llm` | Get the authenticated user's LLM provider settings (masked key) | `200` | +| `PUT` | `/api/v1/settings/llm` | Insert or update the authenticated user's LLM provider settings | `200` | +| `DELETE` | `/api/v1/settings/llm` | Delete the authenticated user's LLM provider settings (idempotent) | `204` | | `POST` | `/api/v1/threads` | Create a new conversation thread (bound to an agent) | `201` | -| `GET` | `/api/v1/threads` | List all threads | `200` | +| `GET` | `/api/v1/threads` | List all threads owned by the authenticated user | `200` | | `GET` | `/api/v1/threads/{thread_id}` | Get a specific thread | `200` | | `DELETE` | `/api/v1/threads/{thread_id}` | Delete a thread | `204` | | `GET` | `/api/v1/threads/{thread_id}/history` | Get thread history grouped by turn (`ThreadHistory`) | `200` | @@ -452,8 +579,9 @@ All endpoints are prefixed appropriately. The server runs on `http://localhost:8 | Status | Condition | |---|---| | `400` | General configuration error | +| `401` | Missing or invalid credentials (no JWT, no API key, or unknown/revoked key) | | `404` | Thread not found, agent not found, or config file not found | -| `422` | Validation error (bad request body, invalid config schema) | +| `422` | Validation error (bad request body, invalid config schema, **or no LLM provider configured for the user** — `LlmNotConfiguredError`) | | `502` | Agent execution error (LLM failure) | | `500` | Unexpected domain error | @@ -476,7 +604,8 @@ Response: ### 2. List Available Agents ```bash -curl http://localhost:8000/api/v1/agents +curl http://localhost:8000/api/v1/agents \ + -H "X-API-Key: " ``` Response (`200`): @@ -507,7 +636,8 @@ Response (`200`): ### 3. Get a Specific Agent Configuration ```bash -curl http://localhost:8000/api/v1/agents/example-agent +curl http://localhost:8000/api/v1/agents/example-agent \ + -H "X-API-Key: " ``` Response (`200`): @@ -532,7 +662,8 @@ Response (`200`): If the agent does not exist: ```bash -curl http://localhost:8000/api/v1/agents/nonexistent +curl http://localhost:8000/api/v1/agents/nonexistent \ + -H "X-API-Key: " ``` Response (`404`): @@ -548,6 +679,7 @@ The `agent_name` must match an existing YAML filename (without the `.yaml` exten ```bash curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"agent_name": "example-agent"}' ``` @@ -568,6 +700,7 @@ If the agent name does not match any YAML file: ```bash curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"agent_name": "nonexistent-agent"}' ``` @@ -582,6 +715,7 @@ Response (`404`): ```bash curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"message": "Explain the hexagonal architecture pattern in 3 sentences."}' ``` @@ -602,6 +736,7 @@ Response (`200`): ```bash curl -N -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890/stream \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"message": "Write a haiku about programming."}' ``` @@ -665,7 +800,8 @@ This design prevents Cloudflare timeout issues (~100s on idle connections) becau ### 6b. Get Thread History ```bash -curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/history +curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/history \ + -H "X-API-Key: " ``` Response (`200`) — `ThreadHistory`: @@ -698,7 +834,8 @@ Response (`200`) — `ThreadHistory`: ### 6c. Get Flat Trace ```bash -curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/trace +curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/trace \ + -H "X-API-Key: " ``` Response (`200`): @@ -718,7 +855,8 @@ Returns the full flat list of `TraceEvent`s for the thread, ordered by `sequence ### 7. List All Threads ```bash -curl http://localhost:8000/api/v1/threads +curl http://localhost:8000/api/v1/threads \ + -H "X-API-Key: " ``` Response (`200`): @@ -745,13 +883,15 @@ Response (`200`): ### 8. Get a Specific Thread ```bash -curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "X-API-Key: " ``` ### 9. List Messages in a Thread ```bash -curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/messages +curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/messages \ + -H "X-API-Key: " ``` Response (`200`): @@ -782,6 +922,7 @@ When the agent is configured with HITL rules and a tool call is interrupted, sub ```bash curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{ "tool_call_id": "call_abc123", "action": "approve" @@ -805,6 +946,7 @@ Response (`200`): ```bash curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{ "tool_call_id": "call_abc123", "action": "reject", @@ -817,6 +959,7 @@ curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234 ```bash curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{ "tool_call_id": "call_abc123", "action": "edit", @@ -827,7 +970,8 @@ curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234 ### 13. Delete a Thread ```bash -curl -X DELETE http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +curl -X DELETE http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "X-API-Key: " ``` Response: `204 No Content` @@ -841,6 +985,7 @@ Prompts are managed via a dedicated registry backed by Phoenix. Enable prompt ma ```bash curl -X POST http://localhost:8000/prompts/create \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{ "identifier": "customer-support", "content": [ @@ -880,7 +1025,8 @@ Response (`200`): #### 14.2 List All Prompts ```bash -curl http://localhost:8000/prompts/customer-support +curl http://localhost:8000/prompts/customer-support \ + -H "X-API-Key: " ``` Optional query parameters: @@ -919,6 +1065,7 @@ Create a new version of an existing prompt: ```bash curl -X PUT http://localhost:8000/prompts/update/customer-support \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{ "content": [ { @@ -955,10 +1102,12 @@ Response (`200`): ### WebSocket -Connect to the WebSocket endpoint and send JSON messages: +Connect to the WebSocket endpoint and send JSON messages. The WebSocket handshake accepts either `Authorization: Bearer ` or `X-API-Key: cpk_...` as headers; if neither validates, the server rejects the upgrade with HTTP 401. ```javascript -const ws = new WebSocket("ws://localhost:8000/api/v1/ws/"); +const ws = new WebSocket("ws://localhost:8000/api/v1/ws/", { + headers: { "X-API-Key": "" } // or "Authorization": "Bearer " +}); ws.onopen = () => ws.send(JSON.stringify({ message: "Hello" })); ws.onmessage = (event) => { if (event.data === "[END]") { @@ -1043,7 +1192,8 @@ The `{path}` segment uses FastAPI's `:path` converter, so it can contain slashes ### Listing files ```bash -curl 'http://localhost:8000/api/v1/store/files?prefix=/skills/' +curl 'http://localhost:8000/api/v1/store/files?prefix=/skills/' \ + -H "X-API-Key: " ``` Response (`200`) — a JSON array of path strings: @@ -1055,7 +1205,8 @@ Response (`200`) — a JSON array of path strings: ### Getting a file ```bash -curl http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md +curl http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md \ + -H "X-API-Key: " ``` Response (`200`): @@ -1071,6 +1222,7 @@ If the file does not exist, the API returns `404` with `{"detail": "File not fou ```bash curl -X PUT http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"content": "# Code Review Skill\n\nReview code for correctness and security."}' ``` @@ -1083,7 +1235,8 @@ Response (`200`): ### Deleting a file ```bash -curl -X DELETE http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md +curl -X DELETE http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md \ + -H "X-API-Key: " ``` Response: `204 No Content`. The operation is idempotent — deleting a non-existent path does not raise. @@ -1099,10 +1252,13 @@ This ensures each agent only loads the skills and memories explicitly selected i When an agent is updated and a skill or memory is **removed** from the selection, the corresponding copy in the agent namespace is **deleted** automatically. +> **Per-user isolation:** As of the `feat/dual-auth-rls-llm-peruser` branch, both the Store File API (`/api/v1/store/files`) and the agent namespace copies (`/agents/{name}/skills/`, `/agents/{name}/memories/`) are scoped **per authenticated user** via a namespace prefix `(user_id, "filesystem")`. A user only ever sees the skills/memories they own. See [Store Namespaces per User](#store-namespaces-per-user). + To discover which agents reference a given skill, use the usage-tracking endpoint: ```bash -curl http://localhost:8000/api/v1/store/skills/my-skill/usage +curl http://localhost:8000/api/v1/store/skills/my-skill/usage \ + -H "X-API-Key: " ``` Response (`200`) — the list of agent names that have `my-skill` in their namespace: @@ -1122,6 +1278,7 @@ Skills are `SKILL.md` files stored in the LangGraph store under the `/skills/` p ```bash curl -X PUT http://localhost:8000/api/v1/store/files/skills/my-skill/SKILL.md \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"content": "# my-skill\n\n## Description\nA skill for ..."}' ``` @@ -1148,6 +1305,7 @@ Memories are Markdown files (e.g. `AGENTS.md`) stored in the LangGraph store, ty ```bash curl -X PUT http://localhost:8000/api/v1/store/files/memories/AGENTS.md \ -H "Content-Type: application/json" \ + -H "X-API-Key: " \ -d '{"content": "# Project Guidelines\n\n- Always write tests.\n- Follow the hexagonal architecture."}' ``` @@ -1165,6 +1323,219 @@ In the frontend agent form, the Memory field is a **multi-select dropdown** (`Pi --- +## Row-Level Security (RLS) + +composable-agents enforces per-user isolation at the **database level** using PostgreSQL Row-Level Security. RLS is enabled **and forced** (so even the table owner is subject to the policies) on every per-user table. + +### RLS-protected tables + +| Table | `user_id` column added by | +|---|---| +| `agent_configs` | migration `012_add_user_id_to_rls_tables` | +| `threads` | migration `012_add_user_id_to_rls_tables` | +| `trace_events` | migration `012_add_user_id_to_rls_tables` | +| `api_keys` | migration `011_create_api_keys_table` (column present at creation) | +| `user_llm_settings` | migration `014_create_user_llm_settings_table` (column is the PK) | + +### How the `app.user_id` GUC is set + +For each authenticated request, the `ComposableAgentsSecurity.verify_credentials` dependency resolves the `user_id` (JWT `sub` or the API key's `user_id`) and stores it in a `contextvars.ContextVar` (`current_user_id`). A SQLAlchemy `before_cursor_execute` event listener on the engine then emits: + +```sql +SET LOCAL app.user_id = ''; +``` + +inside the current transaction. The RLS policies compare each row's `user_id` against this GUC: + +```sql +CREATE POLICY _user_isolation ON
+ USING (user_id = current_setting('app.user_id', true)) + WITH CHECK (user_id = current_setting('app.user_id', true)); +``` + +When the GUC is unset (unauthenticated session), `current_setting(..., true)` returns `NULL` and the policy filters out **every** row — the defensive default. + +### Bypassing RLS (migrations / background jobs) + +System operations that must read across all users (Alembic migrations, cron jobs) wrap their work in the `system_rls_context()` async context manager, which sets a `bypass_rls` contextvar that makes the listener emit `SET LOCAL row_security = off` for that transaction. + +### Tables NOT protected by RLS + +The LangGraph **`store`** table is **not** RLS-protected. It is accessed via its own asyncpg connection pool (the `PostgresStore` from `langgraph-checkpoint-postgres`), not via the SQLAlchemy engine that sets the `app.user_id` GUC. Per-user isolation for skills and memories is therefore enforced at the **application layer** via namespace prefixes — see [Store Namespaces per User](#store-namespaces-per-user). + +--- + +## Per-User LLM Settings + +Each authenticated user configures their **own** OpenAI-compatible LLM provider (provider label, base URL, and API key). The agent factory resolves the credentials per request from the authenticated `user_id` and builds a per-user `ChatOpenAI` instance. The server-wide `OPENAI_API_KEY` env var is **no longer used** to call the LLM. + +### Endpoints + +| Method | Path | Description | Success Status | +|---|---|---|---| +| `GET` | `/api/v1/settings/llm` | Get the authenticated user's LLM settings (masked key) | `200` (or `null` body if not configured) | +| `PUT` | `/api/v1/settings/llm` | Insert or update the user's LLM settings | `200` | +| `DELETE` | `/api/v1/settings/llm` | Delete the user's LLM settings (idempotent) | `204` | + +The `api_key` is plaintext on the wire (HTTPS) and stored **encrypted at rest** with Fernet using `SECRET_ENCRYPTION_KEY`. GET responses return only a masked preview (`api_key_masked`), never the plaintext. + +### Get current settings + +```bash +curl http://localhost:8000/api/v1/settings/llm \ + -H "X-API-Key: " +``` + +Response (`200`) — `null` when nothing is configured yet: + +```json +{ + "user_id": "qa-user-1", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key_masked": "sk-or-v1-8e1a…b147", + "created_at": "2026-07-27T10:00:00Z", + "updated_at": "2026-07-27T10:00:00Z" +} +``` + +### Configure (insert or update) + +```bash +curl -X PUT http://localhost:8000/api/v1/settings/llm \ + -H "Content-Type: application/json" \ + -H "X-API-Key: " \ + -d '{ + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "sk-or-v1-..." + }' +``` + +Response (`200`): + +```json +{ + "user_id": "qa-user-1", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key_masked": "sk-or-v1-8e1a…b147", + "created_at": "2026-07-27T10:00:00Z", + "updated_at": "2026-07-27T10:05:00Z" +} +``` + +### Delete settings + +```bash +curl -X DELETE http://localhost:8000/api/v1/settings/llm \ + -H "X-API-Key: " +``` + +Response: `204 No Content` (idempotent — deleting non-existent settings does not raise). + +### Missing LLM configuration + +If the user invokes an agent (e.g. `POST /api/v1/chat/{thread_id}`) **before** configuring an LLM provider, the agent factory raises `LlmNotConfiguredError` and the API returns: + +```json +{"detail": "No LLM provider configured for user . Configure via PUT /api/v1/settings/llm."} +``` + +with HTTP status **`422`**. The user must call `PUT /api/v1/settings/llm` first. + +--- + +## Store Namespaces per User + +The LangGraph Store (used by the [Store File API](#store-file-api) and the per-agent skill/memory namespaces) is **not** RLS-protected (see [Row-Level Security (RLS)](#row-level-security-rls)). Instead, per-user isolation is enforced at the **application layer** via namespace prefixes. + +When a request is authenticated, the `LangGraphStoreFileRepository` resolves its namespace as: + +```python +(user_id, "filesystem") +``` + +so a file written by `qa-user-1` is stored under the namespace `("qa-user-1", "filesystem")` and is invisible to `qa-user-2`. The same prefixing applies to the agent namespace copies (`/agents/{name}/skills/`, `/agents/{name}/memories/`). + +When no user is authenticated (tests, system context), the namespace falls back to the static default `("filesystem",)` so existing tests stay green. + +### Practical consequences + +- `GET /api/v1/store/files?prefix=/skills/` lists **only the calling user's** skills. +- `PUT /api/v1/store/files/skills/my-skill/SKILL.md` writes into **the calling user's** namespace. +- An agent's `/agents/{name}/skills/` and `/agents/{name}/memories/` copies are scoped to the user who owns the agent config (RLS on `agent_configs` ensures the agent itself is per-user). + +--- + +## Per-User API Keys + +Per-user API keys allow a user to authenticate without a JWT (e.g. from a CI pipeline or a script). Keys are SHA-256 hashed at rest; the plaintext is returned **exactly once** at creation time. + +### Endpoints + +| Method | Path | Description | Success Status | +|---|---|---|---| +| `POST` | `/api/v1/api-keys` | Create a new API key (returns plaintext once) | `201` | +| `GET` | `/api/v1/api-keys` | List the user's API keys (no plaintext) | `200` | +| `DELETE` | `/api/v1/api-keys/{key_id}` | Revoke an API key (idempotent) | `204` | + +> Creating an API key requires an existing authenticated session (JWT). In QA/local, keys are seeded directly in the database (see [Testing](#testing)). + +### Create an API key + +```bash +curl -X POST http://localhost:8000/api/v1/api-keys \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"name": "my-ci-key"}' +``` + +Response (`201`): + +```json +{ + "id": "8f3c1d2e-...", + "name": "my-ci-key", + "key_prefix": "cpk_abcde", + "plaintext": "cpk_abcdefghijklmnopqrstuvwxyz0123456789...", + "created_at": "2026-07-27T10:00:00Z" +} +``` + +### List API keys + +```bash +curl http://localhost:8000/api/v1/api-keys \ + -H "X-API-Key: " +``` + +Response (`200`) — a list of safe projections (no hash, no plaintext): + +```json +[ + { + "id": "8f3c1d2e-...", + "name": "my-ci-key", + "key_prefix": "cpk_abcde", + "created_at": "2026-07-27T10:00:00Z", + "last_used_at": "2026-07-27T11:30:00Z", + "revoked_at": null + } +] +``` + +### Revoke an API key + +```bash +curl -X DELETE http://localhost:8000/api/v1/api-keys/8f3c1d2e-... \ + -H "X-API-Key: " +``` + +Response: `204 No Content` (idempotent — revoking an already-revoked key returns 204). Revoking a key owned by another user returns `404` (RLS hides it). + +--- + ## Architecture composable-agents follows a strict **hexagonal architecture** (ports and adapters). The domain layer has zero dependencies on frameworks or infrastructure. @@ -1219,21 +1590,31 @@ composable-agents/ versions/ 001_create_agent_configs_table.py 002_create_threads_and_messages_tables.py - 005_create_trace_events_table.py # Create trace_events table - 006_migrate_messages_to_trace_events.py # Backfill trace_events from messages - 007_drop_messages_table.py # Drop legacy messages table + 005_create_trace_events_table.py + 006_migrate_messages_to_trace_events.py + 007_drop_messages_table.py + 010_add_description_to_agent_configs.py + 011_create_api_keys_table.py # Per-user API keys (SHA-256 hashed) + 012_add_user_id_to_rls_tables.py # Add user_id to agent_configs/threads/trace_events + 013_enable_rls_policies.py # Enable + force RLS, create per-user policies + 014_create_user_llm_settings_table.py # Per-user LLM settings (Fernet-encrypted) + security.py # Dual-auth FastAPI dependency (JWT + per-user API key) application/ requests/ chat.py # Request models (ChatRequest, CreateThreadRequest, HITLDecisionRequest) + api_key.py # CreateApiKeyRequest + user_llm_settings.py # UpsertUserLlmSettingsRequest responses/ - thread_history.py # ThreadHistory response DTO (thread + turns) + thread_history.py # ThreadHistory response DTO (thread + turns) routes/ - health.py # GET /health + health.py # GET /health (public) threads.py # CRUD /api/v1/threads chat.py # POST /api/v1/chat/{id} and /stream trace.py # GET /api/v1/threads/{id}/history and /trace agents.py # GET /api/v1/agents store.py # Store File API — /api/v1/store/files + api_keys.py # POST/GET/DELETE /api/v1/api-keys (per-user API keys) + user_llm_settings.py # GET/PUT/DELETE /api/v1/settings/llm (per-user LLM) websocket.py # WS /api/v1/ws/{id} use_cases/ send_message.py # Invoke agent synchronously @@ -1248,15 +1629,19 @@ composable-agents/ load_agent_config.py # Load and validate a YAML config seed_agents.py # Seed built-in agents from agents/ dir thread_management.py # Create / get / list / delete threads + api_key/ # create / list / revoke per-user API keys + user_llm_settings/ # get / upsert / delete per-user LLM settings domain/ entities/ agent_config.py # AgentConfig, BackendConfig, HITLConfig, SubAgentConfig agent_config_metadata.py # AgentConfigMetadata (incl. description) mcp_server_config.py # McpServerConfig, McpTransportType message.py # Message (role, content, timestamp, tool_calls) — projection model - thread.py # Thread (id, agent_name, timestamps) — no more MessageModel - trace_event.py # TraceEvent entity + TraceEventType enum (6 types) + thread.py # Thread (id, agent_name, user_id, timestamps) + trace_event.py # TraceEvent entity + TraceEventType enum (6 types) tracing_config.py # TracingConfig, TracingProviderType + user_llm_settings.py # UserLlmSettings, UserLlmSettingsInput + auth/ # AuthContext, ApiKeyView, CreatedApiKey ports/ agent_config_loader.py # Abstract: load config from file agent_config_repository.py # Abstract: CRUD for agent config metadata @@ -1264,26 +1649,34 @@ composable-agents/ agent_registry.py # Abstract: get_runner(name), list_agents(), close() agent_runner.py # Abstract: invoke, stream, HITL operations mcp_tool_loader.py # Abstract: load MCP tools - store_file_repository.py # Abstract: file CRUD on the LangGraph store (StoreFileRepository port) + store_file_repository.py # Abstract: file CRUD on the LangGraph store (StoreFileRepository port) thread_repository.py # Abstract: CRUD for threads trace_event_repository.py # Abstract: persist/append/list TraceEvents tracing_provider.py # Abstract: tracing lifecycle - exceptions.py # DomainError hierarchy (incl. AgentNotFoundError, StorageError) + api_key_repository.py # Abstract: per-user API key CRUD + hash lookup + user_llm_settings_repository.py # Abstract: per-user LLM settings CRUD + jwt_validator.py # Abstract: JWT validation against Logto JWKS + services/auth/ # AuthService (dual JWT + API key orchestration) + errors/ # DomainError hierarchy (incl. AuthenticationError, LlmNotConfiguredError) infrastructure/ - env_utils.py # ${VAR_NAME} environment variable resolution + env_utils.py # ${VAR_NAME} + ${USER_JWT}/${USER_API_KEY} resolution database/ + rls_context.py # Per-request RLS contextvars + system_rls_context bypass + rls_listener.py # SQLAlchemy before_cursor_execute listener (SET LOCAL app.user_id) models/ base.py # SQLAlchemy DeclarativeBase - agent_config.py # AgentConfigModel (ORM) - thread.py # ThreadModel (ORM) — MessageModel removed - trace_event.py # TraceEventModel (ORM) + agent_config.py # AgentConfigModel (ORM, incl. user_id) + thread.py # ThreadModel (ORM, incl. user_id) + trace_event.py # TraceEventModel (ORM, incl. user_id) + api_key.py # ApiKeyModel (ORM) + user_llm_settings.py # UserLlmSettingModel (ORM) deepagent/ adapter.py # DeepAgentRunner (LangGraph adapter) — emits TraceEvent - factory.py # create_agent_from_config (resolves tools, backend) + factory.py # create_agent_from_config (per-user LLM credential resolution) registry.py # DeepAgentRegistry (lazy loading + caching from agents/ dir) example_tools.py # Example tools: current_time, word_count mcp/ - adapter.py # LangchainMcpToolLoader + adapter.py # LangchainMcpToolLoader (resolves ${USER_JWT}/${USER_API_KEY}) minio_store/ adapter.py # MinioAgentConfigStore (YAML blob storage) persistent_registry/ @@ -1432,23 +1825,26 @@ subagents: ## Database (PostgreSQL) -Thread and agent config persistence is backed by PostgreSQL, accessed via SQLAlchemy's async ORM (`asyncpg` driver). +Thread, agent config, API key, and LLM-settings persistence is backed by PostgreSQL, accessed via SQLAlchemy's async ORM (`asyncpg` driver). Per-user tables are protected by Row-Level Security (see [Row-Level Security (RLS)](#row-level-security-rls)). ### Schema The database uses a flat normalized schema. Thread persistence relies on a single `trace_events` table as the source of truth for all conversation activity (the legacy `messages` table has been dropped — see [Breaking changes](#breaking-changes)). -| Table | Description | -|---|---| -| `threads` | One row per conversation thread. Columns: `id` (PK, VARCHAR 36), `agent_name`, `created_at`, `updated_at`. | -| `trace_events` | One row per trace event. Columns: `id` (PK), `thread_id` (FK to `threads.id`, CASCADE delete), `turn_id`, `type` (enum: `HUMAN_MESSAGE`, `AI_MESSAGE`, `THINKING`, `CONTENT`, `TOOL_CALL`, `TOOL_RESULT`), `source` (sub-agent name or null), `name` (tool name or null), `content` (text), `metadata` (JSONB), `timestamp`, `sequence` (int, monotonic per thread). | -| `agent_configs` | Agent configuration metadata. | +| Table | Description | RLS? | +|---|---|---| +| `threads` | One row per conversation thread. Columns: `id` (PK, VARCHAR 36), `agent_name`, `user_id`, `created_at`, `updated_at`. | ✅ | +| `trace_events` | One row per trace event. Columns: `id` (PK), `thread_id` (FK to `threads.id`, CASCADE delete), `turn_id`, `type` (enum), `source`, `name`, `content`, `metadata` (JSONB), `timestamp`, `sequence`, `user_id`. | ✅ | +| `agent_configs` | Agent configuration metadata (incl. `description`, `user_id`). | ✅ | +| `api_keys` | Per-user API keys (SHA-256 hashed). Columns: `id` (PK), `user_id`, `name`, `key_hash`, `key_prefix`, `revoked_at`, `last_used_at`, `created_at`. Unique index on `key_hash`. | ✅ | +| `user_llm_settings` | Per-user LLM provider settings (Fernet-encrypted `api_key_encrypted`). PK is `user_id` — one configured provider per user. | ✅ | Indexes on `trace_events`: - `ix_trace_events_thread_id` — fast lookup of all events for a thread. - `ix_trace_events_thread_id_sequence` — ordered retrieval of events within a thread (used by `/trace` and `/history`). - `ix_trace_events_thread_id_turn_id` — grouping events by turn (used by `/history`). +- `ix_trace_events_user_id` — per-user RLS filter. The `messages` table has been **dropped** (migration `007`). Its data was backfilled into `trace_events` by migration `006` (each old `Message` row became a `HUMAN_MESSAGE` or `AI_MESSAGE` event). The legacy `GET /api/v1/threads/{id}/messages` endpoint is preserved as a backward-compatible projection that filters `trace_events` to `HUMAN_MESSAGE` + `AI_MESSAGE` rows. @@ -1456,7 +1852,7 @@ The `messages` table has been **dropped** (migration `007`). Its data was backfi Alembic migrations live in `src/alembic/versions/` and run **automatically at startup** (via `asyncio.to_thread()` in the FastAPI lifespan). You never need to run `alembic upgrade` manually in normal operation. -Relevant migrations for the trace events refactor: +Relevant migrations: | Migration | Description | |---|---| @@ -1464,6 +1860,10 @@ Relevant migrations for the trace events refactor: | `006_migrate_messages_to_trace_events` | Backfills `trace_events` from existing `messages` rows (`role = "human"` → `HUMAN_MESSAGE`, `role = "ai"` → `AI_MESSAGE`). | | `007_drop_messages_table` | Drops the legacy `messages` table. | | `010_add_description_to_agent_configs` | Adds a `description VARCHAR(500)` column to the `agent_configs` table. | +| `011_create_api_keys_table` | Creates the `api_keys` table (per-user API keys, SHA-256 hashed). Unique index on `key_hash`, index on `user_id`. | +| `012_add_user_id_to_rls_tables` | Adds a `user_id VARCHAR(255)` column to `agent_configs`, `threads`, and `trace_events` so RLS policies can filter rows per user. Adds an index on `user_id` for each table. | +| `013_enable_rls_policies` | Enables **and forces** Row-Level Security on `agent_configs`, `threads`, `trace_events`, and `api_keys`. Creates the per-user `user_isolation` policy on each table. | +| `014_create_user_llm_settings_table` | Creates the `user_llm_settings` table (per-user LLM provider settings, Fernet-encrypted API key) and enables RLS with a per-user policy. | > The `mcp_servers` table is **not** managed here. It is owned by mcp-raganything's Alembic migration `001_create_mcp_servers_table` (tracked in the `raganything_alembic_version` table). @@ -1502,7 +1902,19 @@ uv run alembic current ## Breaking Changes -This release replaces the legacy `StreamEvent` / `messages`-based model with a unified `TraceEvent` model. +This release replaces the legacy `StreamEvent` / `messages`-based model with a unified `TraceEvent` model, and switches auth from a single master `X-API-Key` to **dual JWT + per-user API keys** with **Row-Level Security** and **per-user LLM credentials**. + +### Dual auth replaces the master `X-API-Key` + +The single master `X-API-Key` / `API_KEY` / `OPENAI_API_KEY` model is **removed** for authentication. Every protected endpoint now requires either `Authorization: Bearer ` (validated against Logto OIDC JWKS, `LOGTO_URL` + `JWT_AUDIENCE`) or `X-API-Key: cpk_...` (per-user, SHA-256 hashed, created via `POST /api/v1/api-keys`). See [Authentication](#authentication). Existing clients sending the old master key will receive `401 {"detail": "Invalid or missing credentials"}`. + +### Per-user LLM credentials (no server-wide `OPENAI_API_KEY`) + +The server-wide `OPENAI_API_KEY` env var is **no longer used** to call the LLM. Each authenticated user must configure their own provider via `PUT /api/v1/settings/llm`. If a user invokes an agent before configuring a provider, the API returns `422 LlmNotConfiguredError`. See [Per-User LLM Settings](#per-user-llm-settings). + +### Row-Level Security on per-user tables + +Migrations `011`–`014` add a `user_id` column to `agent_configs`, `threads`, and `trace_events`, create the `api_keys` and `user_llm_settings` tables, and enable **forced** Row-Level Security on all five tables. Existing rows become `user_id = ''` and are **invisible** under RLS (the policy compares against `current_setting('app.user_id', true)` which is NULL for unauthenticated sessions). Backfill existing rows to a real `user_id` before enabling RLS in production if you need to preserve access. See [Row-Level Security (RLS)](#row-level-security-rls). ### `StreamEvent` removed @@ -1523,7 +1935,7 @@ Adapters and tests calling the old `invoke(thread_id, message) -> Message` / `st ### Migrations -Migrations `005`, `006`, `007` run automatically on startup. They are idempotent and safe to run on an existing database with data. +Migrations `005`, `006`, `007`, `010`, `011`, `012`, `013`, `014` run automatically on startup. They are idempotent and safe to run on an existing database with data, **except** that `012`/`013` will make pre-existing rows with `user_id = ''` invisible under RLS — backfill them first. --- @@ -1531,14 +1943,33 @@ Migrations `005`, `006`, `007` run automatically on startup. They are idempotent Configured via `.env` file or environment variables. See `.env.example`. +### General + | Variable | Default | Description | |---|---|---| | `AGENTS_DIR` | `./agents` | Directory containing agent YAML configuration files. | -| `OPENAI_API_KEY` | -- | API key for OpenAI models. | -| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Base URL for OpenAI-compatible endpoints. Set to use OpenRouter, LiteLLM, vLLM, etc. | | `HOST` | `0.0.0.0` | Server bind host. | | `PORT` | `8000` | Server bind port. | -| `MCP_RAGANYTHING_API_KEY` | -- | Shared API key for authenticating to mcp-raganything MCP servers. Must match the `API_KEY` set on the raganything server. | +| `LOG_LEVEL` | `INFO` | Application log level. | +| `UVICORN_LOG_LEVEL` | `info` | Uvicorn log level. | +| `ALLOWED_ORIGINS` | `["http://localhost:8080"]` | JSON array of CORS allowed origins. | +| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | Base URL for OpenAI-compatible endpoints. Set to use OpenRouter, LiteLLM, vLLM, etc. **Per-user** base URLs configured via `PUT /api/v1/settings/llm` override this for authenticated requests. | +| `MCP_RAGANYTHING_API_KEY` | -- | Shared API key for authenticating to mcp-raganything MCP servers via `${MCP_RAGANYTHING_API_KEY}` in agent YAML. Must match the `API_KEY` set on the raganything server. | + +### Dual Authentication + +| Variable | Default | Description | +|---|---|---| +| `LOGTO_URL` | `""` (empty) | Logto OIDC issuer URL for JWT validation (e.g. `https://logto.soludev.tech`). When empty, the JWT path is disabled and only the per-user API key path is active (QA/local mode). | +| `JWT_AUDIENCE` | `""` (empty) | Expected `aud` claim for incoming JWTs (typically the Logto app ID). When `LOGTO_URL` is set, this **must** be set too. | +| `SECRET_ENCRYPTION_KEY` | `""` (empty) | Fernet key used to encrypt per-user LLM API keys at rest. Generate with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. **Required** when per-user LLM settings are enabled. Must be stable across restarts. | + +### Deprecated (no longer used for auth / LLM) + +| Variable | Status | Replacement | +|---|---|---| +| `OPENAI_API_KEY` | **Deprecated** for both auth and LLM calls. Still read as an env fallback by `ChatOpenAI` when no authenticated user is present (tests). | Per-user LLM credentials via `PUT /api/v1/settings/llm` (see [Per-User LLM Settings](#per-user-llm-settings)). | +| `API_KEY` (master `X-API-Key`) | **Deprecated** for auth. | Dual JWT + per-user API keys (see [Authentication](#authentication)). | ### PostgreSQL Variables @@ -1581,6 +2012,10 @@ uv sync ### Run the test suite ```bash +# Unit tests (no DB / no Docker required) — 667 tests +uv run pytest tests/unit -q + +# Full suite (unit + integration) uv run pytest tests/ -v ``` @@ -1590,6 +2025,56 @@ uv run pytest tests/ -v uv run pytest tests/ -v --cov=src ``` +## Testing + +The project has two test layers: **unit tests** (in this repo, no infrastructure) and a **QA suite** (in `soludev-compose-apps/bricks/qa`, runs against the local Docker compose stack). + +### Unit tests + +```bash +cd bricks/composable-agents +uv run pytest tests/unit -q +# → 667 passed +``` + +Unit tests use in-memory fixtures (`Base.metadata.create_all` on SQLite, in-memory repositories) — no PostgreSQL, no Logto, no MinIO. The RLS migrations do **not** run in unit tests (RLS is Postgres-only and validated in QA). + +### QA suite (dual-auth + RLS + per-user LLM + store namespaces) + +The QA suite exercises the full dual-auth + RLS + per-user LLM + store-namespace feature against a real PostgreSQL instance via Docker compose. It lives in `soludev-compose-apps/bricks/qa`. + +#### 1. Start the stack + +```bash +cd soludev-compose-apps/bricks +docker compose up -d --build composable-agents bricks-db minio composable-agents-qa-init +docker compose ps # wait for composable-agents to be healthy +``` + +The `composable-agents-qa-init` one-shot service waits for `composable-agents` to be healthy (so the Alembic migrations that create `api_keys` have applied), then seeds two per-user API keys directly in `bricks-db`: + +| `user_id` | plaintext `X-API-Key` | `id` | +|---|---|---| +| `qa-user-1` | `cpk_qa_test_key_12345` | `qa-key-id-1` | +| `qa-user-2` | `cpk_qa_test_key_67890` | `qa-key-id-2` | + +The seed is idempotent (`ON CONFLICT DO UPDATE` re-activates revoked rows), so re-running `docker compose up -d composable-agents-qa-init` is safe. In QA, `LOGTO_URL` is empty, so only the per-user API key path is exercised (JWT validation is unit-tested separately). + +#### 2. Run the QA tests + +```bash +cd qa +uv run pytest # whole suite +# or just the dual-auth / RLS / per-user feature tests: +uv run pytest test_dual_auth.py test_api_keys_crud.py test_rls_isolation.py \ + test_llm_settings.py test_store_isolation.py test_api_keys.py \ + test_threads.py test_agents.py test_health.py --tb=short +``` + +The QA fixtures (`qa/conftest.py`) read `QA_API_KEY_USER_1` / `QA_API_KEY_USER_2` (defaulting to the seeded plaintext keys above) and send them as `X-API-Key` headers automatically. `COMPOSABLE_AGENTS_URL` defaults to `http://localhost:8010` (the port mapped in `docker-compose.yml`). + +> **Note:** `test_mcp_bricks_endpoints.py`, `test_txt_support.py`, `test_file_endpoints.py`, and `test_extraction*.py` target the **raganything-api** service and require `API_KEY` to be set for that service — they are independent of the dual-auth feature. + ### Lint ```bash @@ -1657,14 +2142,17 @@ Railway project | Variable | Example | Notes | |----------|---------|-------| - | `OPENAI_API_KEY` | `sk-...` | OpenAI API key | - | `OPENAI_BASE_URL` | `https://openrouter.ai/api/v1` | OpenAI-compatible endpoint | - | `DATABASE_URL` | `postgresql://postgres:pass@roundhouse.proxy.rlwy.net:33019/railway` | Railway PostgreSQL connection URL | + | `DATABASE_URL` | `postgresql://postgres:pass@roundhouse.proxy.rlwy.net:33019/railway` | Railway PostgreSQL connection URL (required) | + | `LOGTO_URL` | `https://logto.soludev.tech` | Logto OIDC issuer URL for JWT validation. Required in prod to enable the JWT auth path. | + | `JWT_AUDIENCE` | `` | Expected `aud` claim for JWTs. Required when `LOGTO_URL` is set. | + | `SECRET_ENCRYPTION_KEY` | `I32ylYwnej8p2Wa72G3FibHBoRNxWlVxWsC5F4LvXSU=` | Fernet key for encrypting per-user LLM API keys at rest. Generate with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. | | `AGENTS_DIR` | `./agents` | Directory containing agent YAML configs | | `MCP_RAGANYTHING_API_KEY` | `your-shared-secret` | Must match `API_KEY` on mcp-raganything | | `TRACING_PROVIDER` | `phoenix` | Tracing backend: `none`, `langfuse`, or `phoenix` | | `PHOENIX_COLLECTOR_ENDPOINT` | `https://phoenix.xxx.railway.app` | Phoenix collector URL | + > **Note:** `OPENAI_API_KEY` and `API_KEY` (master) are **deprecated** for auth and LLM. Each end-user now configures their own LLM provider via `PUT /api/v1/settings/llm` (see [Per-User LLM Settings](#per-user-llm-settings)). `OPENAI_BASE_URL` is still read as a fallback for unauthenticated/test contexts. + 5. **Update agent YAML configs** to point MCP server URLs to the Railway-deployed mcp-raganything domain: ```yaml @@ -1674,9 +2162,12 @@ Railway project url: https://mcp-raganything-production.up.railway.app/bricks/mcp headers: X-API-Key: "${MCP_RAGANYTHING_API_KEY}" + # Or, to forward the caller's identity to raganything: + # Authorization: "Bearer ${USER_JWT}" + # X-API-Key: "${USER_API_KEY}" ``` - The `${MCP_RAGANYTHING_API_KEY}` placeholder is resolved from the environment variable at runtime. + The `${MCP_RAGANYTHING_API_KEY}` placeholder is resolved from the environment variable at runtime; `${USER_JWT}` / `${USER_API_KEY}` are resolved from the authenticated caller's credential (see [Per-user credential propagation](#per-user-credential-propagation-user_jwt--user_api_key)). 6. **MinIO** (optional — only if using MinIO for agent config storage): - Deploy MinIO as a separate Railway service or use an external S3-compatible service. diff --git a/agents/single/haiku-files-local-structured.yaml b/agents/single/haiku-files-local-structured.yaml index 4914d0c..dbcd2e1 100644 --- a/agents/single/haiku-files-local-structured.yaml +++ b/agents/single/haiku-files-local-structured.yaml @@ -144,4 +144,5 @@ mcp_servers: transport: http url: http://raganything-api:8000/bricks/mcp headers: - X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file + Authorization: "Bearer ${USER_JWT}" + X-API-Key: "${USER_API_KEY}" \ No newline at end of file diff --git a/agents/single/haiku-files-local.yaml b/agents/single/haiku-files-local.yaml index 6a1bf4f..a5041d1 100644 --- a/agents/single/haiku-files-local.yaml +++ b/agents/single/haiku-files-local.yaml @@ -181,4 +181,5 @@ mcp_servers: transport: http url: http://raganything-api:8000/bricks/mcp headers: - X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file + Authorization: "Bearer ${USER_JWT}" + X-API-Key: "${USER_API_KEY}" \ No newline at end of file diff --git a/agents/single/haiku-rag-formation.yaml b/agents/single/haiku-rag-formation.yaml index 6cb34c0..798cee0 100644 --- a/agents/single/haiku-rag-formation.yaml +++ b/agents/single/haiku-rag-formation.yaml @@ -65,3 +65,6 @@ mcp_servers: - name: raganything transport: http url: http://raganything-api:8000/classical/mcp + headers: + Authorization: "Bearer ${USER_JWT}" + X-API-Key: "${USER_API_KEY}" diff --git a/agents/single/haiku-rag-local.yaml b/agents/single/haiku-rag-local.yaml index 16b2fa1..0b88d7e 100644 --- a/agents/single/haiku-rag-local.yaml +++ b/agents/single/haiku-rag-local.yaml @@ -73,4 +73,5 @@ mcp_servers: transport: http url: http://raganything-api:8000/classical/mcp headers: - X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file + Authorization: "Bearer ${USER_JWT}" + X-API-Key: "${USER_API_KEY}" \ No newline at end of file diff --git a/agents/single/haiku-rag.yaml b/agents/single/haiku-rag.yaml index 9ddce21..f1de70d 100644 --- a/agents/single/haiku-rag.yaml +++ b/agents/single/haiku-rag.yaml @@ -195,4 +195,5 @@ mcp_servers: transport: http url: https://raganything.soludev.tech/classical/mcp headers: - X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file + Authorization: "Bearer ${USER_JWT}" + X-API-Key: "${USER_API_KEY}" \ No newline at end of file diff --git a/src/alembic/env.py b/src/alembic/env.py index 0915778..335737b 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -10,9 +10,11 @@ # Side-effect imports: register all models so Base.metadata is populated from src.infrastructure.database.models.agent_config import AgentConfigModel # noqa: F401 +from src.infrastructure.database.models.api_key import ApiKeyModel # noqa: F401 from src.infrastructure.database.models.base import Base from src.infrastructure.database.models.thread import ThreadModel # noqa: F401 from src.infrastructure.database.models.trace_event import TraceEventModel # noqa: F401 +from src.infrastructure.database.models.user_llm_setting import UserLlmSettingModel # noqa: F401 from src.infrastructure.logging import configure_logging config = context.config diff --git a/src/alembic/versions/011_create_api_keys_table.py b/src/alembic/versions/011_create_api_keys_table.py new file mode 100644 index 0000000..4d1af4c --- /dev/null +++ b/src/alembic/versions/011_create_api_keys_table.py @@ -0,0 +1,51 @@ +"""Create api_keys table. + +Revision ID: 011 +Revises: 010 +Create Date: 2026-07-27 + +Creates the ``api_keys`` table that stores per-user API keys (SHA-256 hashed). +Indexes: + +* ``ix_api_keys_user_id`` on ``user_id`` — speeds up ``list_by_user``. +* ``ix_api_keys_key_hash`` UNIQUE on ``key_hash`` — speeds up the auth hot-path + lookup ``find_active_by_hash`` and guarantees no duplicate hashes. + +This migration does NOT add Row-Level Security policies or a ``user_id`` column +to ``agent_configs`` / ``threads`` / ``trace_events`` — those belong to a +later RLS layer. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "011" +down_revision: str | None = "010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS api_keys ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + name VARCHAR(200) NOT NULL, + key_hash VARCHAR(64) NOT NULL, + key_prefix VARCHAR(12) NOT NULL, + revoked_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + """ + ) + op.execute("CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys (user_id);") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_api_keys_key_hash ON api_keys (key_hash);") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_api_keys_key_hash;") + op.execute("DROP INDEX IF EXISTS ix_api_keys_user_id;") + op.execute("DROP TABLE IF EXISTS api_keys;") diff --git a/src/alembic/versions/012_add_user_id_to_rls_tables.py b/src/alembic/versions/012_add_user_id_to_rls_tables.py new file mode 100644 index 0000000..ed6296a --- /dev/null +++ b/src/alembic/versions/012_add_user_id_to_rls_tables.py @@ -0,0 +1,54 @@ +"""Add user_id column to agent_configs, threads, trace_events. + +Revision ID: 012 +Revises: 011 +Create Date: 2026-07-27 + +Adds a ``user_id`` column (``VARCHAR(255) NOT NULL DEFAULT ''``) to the +``agent_configs``, ``threads`` and ``trace_events`` tables so that Row-Level +Security policies can filter rows per authenticated user. + +Existing rows become ``user_id = ''`` — they are invisible under RLS (the +policies added in migration 013 compare against +``current_setting('app.user_id', true)`` which is NULL for unauthenticated +sessions) but still visible in SQLite tests (no RLS policies). + +An index is added on ``user_id`` for each table to keep the per-user filter +fast. + +This migration is Postgres-only (raw SQL). In tests, migrations do not run — +the ``db_engine`` fixture uses ``Base.metadata.create_all`` which already +includes the ``user_id`` column on the ORM models. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "012" +down_revision: str | None = "011" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # agent_configs + op.execute("ALTER TABLE agent_configs ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) NOT NULL DEFAULT '';") + op.execute("CREATE INDEX IF NOT EXISTS ix_agent_configs_user_id ON agent_configs (user_id);") + + # threads + op.execute("ALTER TABLE threads ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) NOT NULL DEFAULT '';") + op.execute("CREATE INDEX IF NOT EXISTS ix_threads_user_id ON threads (user_id);") + + # trace_events + op.execute("ALTER TABLE trace_events ADD COLUMN IF NOT EXISTS user_id VARCHAR(255) NOT NULL DEFAULT '';") + op.execute("CREATE INDEX IF NOT EXISTS ix_trace_events_user_id ON trace_events (user_id);") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_trace_events_user_id;") + op.execute("ALTER TABLE trace_events DROP COLUMN IF EXISTS user_id;") + op.execute("DROP INDEX IF EXISTS ix_threads_user_id;") + op.execute("ALTER TABLE threads DROP COLUMN IF EXISTS user_id;") + op.execute("DROP INDEX IF EXISTS ix_agent_configs_user_id;") + op.execute("ALTER TABLE agent_configs DROP COLUMN IF EXISTS user_id;") diff --git a/src/alembic/versions/013_enable_rls_policies.py b/src/alembic/versions/013_enable_rls_policies.py new file mode 100644 index 0000000..39ff9ae --- /dev/null +++ b/src/alembic/versions/013_enable_rls_policies.py @@ -0,0 +1,64 @@ +"""Enable RLS and create per-user policies on agent_configs, threads, trace_events, api_keys. + +Revision ID: 013 +Revises: 012 +Create Date: 2026-07-27 + +Postgres-only migration (raw ``op.execute`` SQL). Enables Row-Level Security +and forces it on (so even the table owner is subject to the policies) on the +four per-user tables, then creates policies that filter rows by +``user_id = current_setting('app.user_id', true)``. + +The ``app.user_id`` GUC is set transaction-scoped (LOCAL) by the SQLAlchemy +``before_cursor_execute`` listener (see +``src.infrastructure.database.rls_listener``) from the ``current_user_id`` +contextvar, which is itself set by +``ComposableAgentsSecurity.verify_credentials`` after a successful JWT / API +key authentication. + +For background jobs / migrations that must read across all users, the +``bypass_rls`` contextvar triggers ``SET LOCAL row_security = off`` in the +listener — the policies are bypassed for that transaction. + +This migration does NOT run in tests (the ``db_engine`` fixture uses +``Base.metadata.create_all``, not Alembic). It is validated in QA against a +real PostgreSQL instance. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "013" +down_revision: str | None = "012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +_TABLES = ("agent_configs", "threads", "trace_events", "api_keys") + + +def upgrade() -> None: + for table in _TABLES: + # Enable RLS and force it on (even the table owner is subject to it). + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY;") + # Per-user policy: a row is visible / insertable / updatable / deletable + # only when its user_id matches the transaction-scoped app.user_id GUC. + # current_setting(..., true) returns NULL when the GUC is unset, so an + # unauthenticated session sees NO rows (defensive default). + op.execute( + f""" + CREATE POLICY {table}_user_isolation + ON {table} + USING (user_id = current_setting('app.user_id', true)) + WITH CHECK (user_id = current_setting('app.user_id', true)); + """ + ) + + +def downgrade() -> None: + for table in _TABLES: + op.execute(f"DROP POLICY IF EXISTS {table}_user_isolation ON {table};") + op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY;") + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;") diff --git a/src/alembic/versions/014_create_user_llm_settings_table.py b/src/alembic/versions/014_create_user_llm_settings_table.py new file mode 100644 index 0000000..aa690ef --- /dev/null +++ b/src/alembic/versions/014_create_user_llm_settings_table.py @@ -0,0 +1,64 @@ +"""Create user_llm_settings table + enable RLS with a per-user policy. + +Revision ID: 014 +Revises: 013 +Create Date: 2026-07-27 + +Creates the ``user_llm_settings`` table that stores per-user OpenAI-compatible +LLM provider settings (provider label, base URL, Fernet-encrypted API key). +``user_id`` is the primary key — one configured provider per user. + +Enables Row-Level Security and forces it on (so even the table owner is subject +to the policy), then creates a per-user policy filtering rows by +``user_id = current_setting('app.user_id', true)``. + +This migration is Postgres-only (raw ``op.execute`` SQL). In tests, migrations +do not run — the ``db_engine`` fixture uses ``Base.metadata.create_all`` which +already includes the ``UserLlmSettingModel``. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "014" +down_revision: str | None = "013" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_TABLE = "user_llm_settings" + + +def upgrade() -> None: + op.execute( + f""" + CREATE TABLE IF NOT EXISTS {_TABLE} ( + user_id VARCHAR(255) PRIMARY KEY, + provider VARCHAR(100) NOT NULL, + base_url VARCHAR(500) NOT NULL, + api_key_encrypted TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + """ + ) + # Enable RLS and force it on (even the table owner is subject to it). + op.execute(f"ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY;") + op.execute(f"ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY;") + # Per-user policy: a row is visible / insertable / updatable / deletable + # only when its user_id matches the transaction-scoped app.user_id GUC. + op.execute( + f""" + CREATE POLICY {_TABLE}_user_isolation + ON {_TABLE} + USING (user_id = current_setting('app.user_id', true)) + WITH CHECK (user_id = current_setting('app.user_id', true)); + """ + ) + + +def downgrade() -> None: + op.execute(f"DROP POLICY IF EXISTS {_TABLE}_user_isolation ON {_TABLE};") + op.execute(f"ALTER TABLE {_TABLE} NO FORCE ROW LEVEL SECURITY;") + op.execute(f"ALTER TABLE {_TABLE} DISABLE ROW LEVEL SECURITY;") + op.execute(f"DROP TABLE IF EXISTS {_TABLE};") diff --git a/src/application/requests/api_key.py b/src/application/requests/api_key.py new file mode 100644 index 0000000..87395e9 --- /dev/null +++ b/src/application/requests/api_key.py @@ -0,0 +1,13 @@ +"""Request DTOs for the API-key management endpoints.""" + +from pydantic import BaseModel, Field + + +class CreateApiKeyRequest(BaseModel): + """Request body for creating a new per-user API key. + + ``name`` is required and must be non-empty; FastAPI returns 422 when the + field is missing or empty (Pydantic ``min_length=1``). + """ + + name: str = Field(..., min_length=1) diff --git a/src/application/requests/user_llm_settings.py b/src/application/requests/user_llm_settings.py new file mode 100644 index 0000000..681d73f --- /dev/null +++ b/src/application/requests/user_llm_settings.py @@ -0,0 +1,15 @@ +"""Request DTOs for the LLM-settings management endpoints.""" + +from pydantic import BaseModel, Field + + +class UpsertUserLlmSettingsRequest(BaseModel): + """Request body for upserting per-user LLM provider settings. + + All fields are required and must be non-empty; FastAPI returns 422 when a + field is missing or empty (Pydantic ``min_length=1``). + """ + + provider: str = Field(..., min_length=1) + base_url: str = Field(..., min_length=1) + api_key: str = Field(..., min_length=1) diff --git a/src/application/routes/api_keys.py b/src/application/routes/api_keys.py new file mode 100644 index 0000000..bead512 --- /dev/null +++ b/src/application/routes/api_keys.py @@ -0,0 +1,89 @@ +"""HTTP routes for per-user API-key management. + +Mounted under ``/api/v1/api-keys``. All endpoints require an authenticated +user id resolved from the request (``get_current_user_id``). The use cases are +injected via FastAPI dependencies so tests can override them. +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from src.application.requests.api_key import CreateApiKeyRequest +from src.application.use_cases.api_key.create_api_key import CreateApiKeyUseCase +from src.application.use_cases.api_key.list_api_keys import ListApiKeysUseCase +from src.application.use_cases.api_key.revoke_api_key import RevokeApiKeyUseCase +from src.dependencies import ( + get_create_api_key_use_case, + get_current_user_id, + get_list_api_keys_use_case, + get_revoke_api_key_use_case, +) +from src.domain.entities.auth.api_key import ApiKeyView, CreatedApiKey +from src.domain.logging.messages import LogMessage + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"]) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_api_key( + body: CreateApiKeyRequest, + user_id: Annotated[str, Depends(get_current_user_id)], + use_case: Annotated[CreateApiKeyUseCase, Depends(get_create_api_key_use_case)], +) -> CreatedApiKey: + """Create a new API key for the authenticated user. + + Args: + body: Request body containing the key ``name``. + user_id: Authenticated user id (injected). + use_case: :class:`CreateApiKeyUseCase` wired at startup. + + Returns: + A :class:`CreatedApiKey` carrying the plaintext (shown once). + """ + return await use_case.execute(user_id=user_id, name=body.name) + + +@router.get("") +async def list_api_keys( + user_id: Annotated[str, Depends(get_current_user_id)], + use_case: Annotated[ListApiKeysUseCase, Depends(get_list_api_keys_use_case)], +) -> list[ApiKeyView]: + """List all API keys owned by the authenticated user. + + Args: + user_id: Authenticated user id (injected). + use_case: :class:`ListApiKeysUseCase` wired at startup. + + Returns: + A list of :class:`ApiKeyView` (no hash, no plaintext). + """ + keys = await use_case.execute(user_id=user_id) + logger.info(LogMessage.API_KEY_LISTED, len(keys), user_id) + return keys + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_api_key( + key_id: str, + user_id: Annotated[str, Depends(get_current_user_id)], + use_case: Annotated[RevokeApiKeyUseCase, Depends(get_revoke_api_key_use_case)], +) -> None: + """Revoke an API key owned by the authenticated user. + + Idempotent: revoking an already-revoked key returns 204. + + Args: + key_id: Id of the key to revoke. + user_id: Authenticated user id (injected). + use_case: :class:`RevokeApiKeyUseCase` wired at startup. + + Raises: + ApiKeyNotFoundError: If the key does not exist or is owned by another + user (HTTP 404). + """ + logger.info(LogMessage.API_KEY_REVOKED, key_id, user_id) + await use_case.execute(user_id=user_id, key_id=key_id) diff --git a/src/application/routes/user_llm_settings.py b/src/application/routes/user_llm_settings.py new file mode 100644 index 0000000..723407e --- /dev/null +++ b/src/application/routes/user_llm_settings.py @@ -0,0 +1,86 @@ +"""HTTP routes for per-user LLM provider settings management. + +Mounted under ``/api/v1/settings/llm``. All endpoints require an authenticated +user id resolved from the request (``get_current_user_id``). The use cases are +injected via FastAPI dependencies so tests can override them. + +The response is the :class:`UserLlmSettings` domain entity directly (FastAPI +serializes it automatically) — no separate Response DTO (KISS). +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from src.application.requests.user_llm_settings import UpsertUserLlmSettingsRequest +from src.application.use_cases.user_llm_settings.delete_user_llm_settings import DeleteUserLlmSettingsUseCase +from src.application.use_cases.user_llm_settings.get_user_llm_settings import GetUserLlmSettingsUseCase +from src.application.use_cases.user_llm_settings.upsert_user_llm_settings import UpsertUserLlmSettingsUseCase +from src.dependencies import ( + get_current_user_id, + get_delete_user_llm_settings_use_case, + get_get_user_llm_settings_use_case, + get_upsert_user_llm_settings_use_case, +) +from src.domain.entities.user_llm_settings import UserLlmSettings, UserLlmSettingsInput + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/settings/llm", tags=["llm-settings"]) + + +@router.get("", status_code=status.HTTP_200_OK) +async def get_llm_settings( + user_id: Annotated[str, Depends(get_current_user_id)], + use_case: Annotated[GetUserLlmSettingsUseCase, Depends(get_get_user_llm_settings_use_case)], +) -> UserLlmSettings | None: + """Get the authenticated user's LLM provider settings (masked API key). + + Args: + user_id: Authenticated user id (injected). + use_case: :class:`GetUserLlmSettingsUseCase` wired at startup. + + Returns: + The :class:`UserLlmSettings` (masked key) or ``null`` if not configured. + """ + return await use_case.execute(user_id) + + +@router.put("", status_code=status.HTTP_200_OK) +async def upsert_llm_settings( + body: UpsertUserLlmSettingsRequest, + user_id: Annotated[str, Depends(get_current_user_id)], + use_case: Annotated[UpsertUserLlmSettingsUseCase, Depends(get_upsert_user_llm_settings_use_case)], +) -> UserLlmSettings: + """Insert or update the authenticated user's LLM provider settings. + + The ``api_key`` is plaintext on the wire (HTTPS) and stored encrypted at + rest by the use case. + + Args: + body: Request body (provider, base_url, api_key). + user_id: Authenticated user id (injected). + use_case: :class:`UpsertUserLlmSettingsUseCase` wired at startup. + + Returns: + The upserted :class:`UserLlmSettings` (masked key). + """ + return await use_case.execute( + user_id=user_id, + inp=UserLlmSettingsInput(**body.model_dump()), + ) + + +@router.delete("", status_code=status.HTTP_204_NO_CONTENT) +async def delete_llm_settings( + user_id: Annotated[str, Depends(get_current_user_id)], + use_case: Annotated[DeleteUserLlmSettingsUseCase, Depends(get_delete_user_llm_settings_use_case)], +) -> None: + """Delete the authenticated user's LLM provider settings (idempotent). + + Args: + user_id: Authenticated user id (injected). + use_case: :class:`DeleteUserLlmSettingsUseCase` wired at startup. + """ + await use_case.execute(user_id) diff --git a/src/application/routes/websocket.py b/src/application/routes/websocket.py index 911a65f..669e20e 100644 --- a/src/application/routes/websocket.py +++ b/src/application/routes/websocket.py @@ -41,8 +41,22 @@ async def websocket_chat( security: Security validator (API key check at handshake). use_case: StreamMessageUseCase wired with real repositories + mock runner. """ - # Validate API key before accepting the WebSocket handshake. - await security.verify_api_key_ws(websocket) + # Dual-auth: JWT bearer token OR per-user API key. verify_credentials_ws + # rejects the handshake with HTTP 401 on failure and returns None; the + # master-key verify_api_key_ws is kept as a backward-compat fallback ONLY + # when no auth service is wired (dev/test without dual auth). When dual + # auth is wired, a 401 from verify_credentials_ws is final — we must NOT + # call websocket.accept() on an already-rejected handshake. + ctx = await security.verify_credentials_ws(websocket) + if ctx is None: + if security.has_auth_service(): + # Dual auth wired: verify_credentials_ws already rejected with 401. + return + # No dual auth wired (master-key only): fall back to the master key. + key = await security.verify_api_key_ws(websocket) + if not key and security.master_key: + # Master-key auth enabled and the key was invalid — already rejected. + return await websocket.accept() logger.info(LogMessage.WS_CONNECTED, thread_id) try: diff --git a/src/application/use_cases/_subagent_ref_utils.py b/src/application/use_cases/_subagent_ref_utils.py index d784c25..812eb6c 100644 --- a/src/application/use_cases/_subagent_ref_utils.py +++ b/src/application/use_cases/_subagent_ref_utils.py @@ -39,9 +39,7 @@ async def validate_subagent_refs( f"Subagent '{sa.name}' references its own agent '{config.name}' (self-reference is not allowed)." ) if not await repository.exists(sa.agent_ref): - raise ConfigError( - f"Subagent '{sa.name}' references unknown agent '{sa.agent_ref}'." - ) + raise ConfigError(f"Subagent '{sa.name}' references unknown agent '{sa.agent_ref}'.") async def invalidate_dependent_agents( diff --git a/src/application/use_cases/api_key/create_api_key.py b/src/application/use_cases/api_key/create_api_key.py new file mode 100644 index 0000000..dc5f611 --- /dev/null +++ b/src/application/use_cases/api_key/create_api_key.py @@ -0,0 +1,63 @@ +"""Use case: create a new per-user API key. + +Generates the plaintext, hashes it (SHA-256), persists the hash + prefix via +the :class:`ApiKeyRepository` port, and returns a :class:`CreatedApiKey` +containing the plaintext exactly once (never persisted). +""" + +import logging +from datetime import UTC, datetime + +from src.domain.entities.auth.api_key import CreatedApiKey +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.security import ApiKeyError +from src.domain.logging.messages import LogMessage +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.infrastructure.auth.api_key_hasher import ApiKeyHasher + +logger = logging.getLogger(__name__) + + +class CreateApiKeyUseCase: + """Create a new API key for a user. + + Depends only on the :class:`ApiKeyRepository` port (DIP). + """ + + def __init__(self, repo: ApiKeyRepository) -> None: + self._repo = repo + + async def execute(self, user_id: str, name: str) -> CreatedApiKey: + """Generate, persist and return a new API key. + + Args: + user_id: Owner of the new key. + name: Human-readable label (must be non-empty / non-whitespace). + + Returns: + A :class:`CreatedApiKey` carrying the plaintext (shown once). + + Raises: + ApiKeyError: If ``name`` is empty or whitespace-only. + """ + if not name or not name.strip(): + raise ApiKeyError(ErrorMessage.API_KEY_NAME_REQUIRED) + + plaintext = ApiKeyHasher.generate_key() + key_hash = ApiKeyHasher.hash_key(plaintext) + key_prefix = plaintext[:10] + key_id = await self._repo.create( + user_id=user_id, + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + ) + created_at = datetime.now(UTC) + logger.info(LogMessage.API_KEY_CREATED, key_id, user_id) + return CreatedApiKey( + id=key_id, + name=name, + key_prefix=key_prefix, + plaintext=plaintext, + created_at=created_at, + ) diff --git a/src/application/use_cases/api_key/list_api_keys.py b/src/application/use_cases/api_key/list_api_keys.py new file mode 100644 index 0000000..89a2e79 --- /dev/null +++ b/src/application/use_cases/api_key/list_api_keys.py @@ -0,0 +1,36 @@ +"""Use case: list all API keys owned by a user. + +Returns safe :class:`ApiKeyView` projections (no hash, no plaintext). Revoked +keys are included. +""" + +import logging + +from src.domain.entities.auth.api_key import ApiKeyView +from src.domain.logging.messages import LogMessage +from src.domain.ports.auth.api_key_repository import ApiKeyRepository + +logger = logging.getLogger(__name__) + + +class ListApiKeysUseCase: + """List all API keys for a user. + + Depends only on the :class:`ApiKeyRepository` port (DIP). + """ + + def __init__(self, repo: ApiKeyRepository) -> None: + self._repo = repo + + async def execute(self, user_id: str) -> list[ApiKeyView]: + """Return all API keys owned by ``user_id`` (newest first). + + Args: + user_id: Owner whose keys are returned. + + Returns: + A list of :class:`ApiKeyView` (possibly empty). + """ + keys = await self._repo.list_by_user(user_id) + logger.info(LogMessage.API_KEY_LISTED, len(keys), user_id) + return keys diff --git a/src/application/use_cases/api_key/revoke_api_key.py b/src/application/use_cases/api_key/revoke_api_key.py new file mode 100644 index 0000000..1655811 --- /dev/null +++ b/src/application/use_cases/api_key/revoke_api_key.py @@ -0,0 +1,37 @@ +"""Use case: revoke an API key owned by a user. + +Delegates to the :class:`ApiKeyRepository` port which raises +:class:`ApiKeyNotFoundError` when no key matches ``(user_id, key_id)`` (the +key does not exist or is owned by another user). Revoking an already-revoked +key is an idempotent no-op success. +""" + +import logging + +from src.domain.logging.messages import LogMessage +from src.domain.ports.auth.api_key_repository import ApiKeyRepository + +logger = logging.getLogger(__name__) + + +class RevokeApiKeyUseCase: + """Revoke an API key for a user. + + Depends only on the :class:`ApiKeyRepository` port (DIP). + """ + + def __init__(self, repo: ApiKeyRepository) -> None: + self._repo = repo + + async def execute(self, user_id: str, key_id: str) -> None: + """Revoke the key ``key_id`` owned by ``user_id``. + + Args: + user_id: Owner of the key. + key_id: Id of the key to revoke. + + Raises: + ApiKeyNotFoundError: If no key matches ``(user_id, key_id)``. + """ + await self._repo.revoke(user_id=user_id, key_id=key_id) + logger.info(LogMessage.API_KEY_REVOKED, key_id, user_id) diff --git a/src/application/use_cases/get_agent_config.py b/src/application/use_cases/get_agent_config.py index d84da1b..6f8f107 100644 --- a/src/application/use_cases/get_agent_config.py +++ b/src/application/use_cases/get_agent_config.py @@ -1,26 +1,40 @@ import logging from src.domain.entities.agent_config import AgentConfig +from src.domain.entities.agent_config_metadata import AgentConfigMetadata from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader +from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore logger = logging.getLogger(__name__) class GetAgentConfigUseCase: - """Retrieve a single agent configuration from persistent storage.""" + """Retrieve a single agent configuration from persistent storage. + + The YAML body lives in object storage (MinIO) which is a *shared* bucket + (not user-scoped), so a by-name lookup would otherwise leak another user's + agent config. To enforce per-user isolation the use case first resolves the + metadata row from the relational repository — which is RLS-filtered by + ``current_user_id`` — and only fetches the YAML when the caller actually + owns the agent. When no metadata row is visible (the agent does not exist + or belongs to another user) :class:`AgentNotFoundError` is raised before + MinIO is ever touched. + """ def __init__( self, config_loader: AgentConfigLoader, config_store: AgentConfigStore, + config_repository: AgentConfigRepository, ) -> None: self._config_loader = config_loader self._config_store = config_store + self._config_repository = config_repository async def execute(self, name: str) -> AgentConfig: - """Fetch YAML from MinIO and parse into AgentConfig. + """Fetch metadata (ownership check) then YAML from MinIO and parse. Args: name: Agent name. @@ -29,10 +43,17 @@ async def execute(self, name: str) -> AgentConfig: Validated AgentConfig. Raises: - AgentNotFoundError: If no YAML exists for this agent. + AgentNotFoundError: If no metadata is visible to the current user + (the agent does not exist or is owned by another user), or if + the YAML is missing from object storage. ConfigError: If the YAML is invalid. """ - yaml_content = await self._config_store.get(name) + # Ownership check: the repository is RLS-filtered by current_user_id, + # so this raises AgentNotFoundError when the agent belongs to another + # user (or does not exist at all) — preventing cross-user leaks via + # the shared MinIO bucket. + metadata: AgentConfigMetadata = await self._config_repository.get(name) + yaml_content = await self._config_store.get(metadata.name) config = self._config_loader.load_from_string(yaml_content) logger.info(LogMessage.AGENT_CONFIG_LOADED_FROM_STORE, name) return config diff --git a/src/application/use_cases/user_llm_settings/delete_user_llm_settings.py b/src/application/use_cases/user_llm_settings/delete_user_llm_settings.py new file mode 100644 index 0000000..7f23afa --- /dev/null +++ b/src/application/use_cases/user_llm_settings/delete_user_llm_settings.py @@ -0,0 +1,26 @@ +"""Use case: delete the authenticated user's LLM provider settings. + +Depends only on the :class:`UserLlmSettingsRepository` port (DIP). Idempotent: +deleting absent settings is a no-op success. +""" + +import logging + +from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository + +logger = logging.getLogger(__name__) + + +class DeleteUserLlmSettingsUseCase: + """Delete the authenticated user's LLM provider settings (idempotent).""" + + def __init__(self, repo: UserLlmSettingsRepository) -> None: + self._repo = repo + + async def execute(self, user_id: str) -> None: + """Delete the user's settings. No-op when absent. + + Args: + user_id: Owner identifier. + """ + await self._repo.delete(user_id) diff --git a/src/application/use_cases/user_llm_settings/get_user_llm_settings.py b/src/application/use_cases/user_llm_settings/get_user_llm_settings.py new file mode 100644 index 0000000..8b22cfe --- /dev/null +++ b/src/application/use_cases/user_llm_settings/get_user_llm_settings.py @@ -0,0 +1,29 @@ +"""Use case: get the authenticated user's LLM provider settings (masked). + +Depends only on the :class:`UserLlmSettingsRepository` port (DIP). +""" + +import logging + +from src.domain.entities.user_llm_settings import UserLlmSettings +from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository + +logger = logging.getLogger(__name__) + + +class GetUserLlmSettingsUseCase: + """Get the authenticated user's LLM provider settings (masked API key).""" + + def __init__(self, repo: UserLlmSettingsRepository) -> None: + self._repo = repo + + async def execute(self, user_id: str) -> UserLlmSettings | None: + """Return the user's settings, or ``None`` if not configured. + + Args: + user_id: Owner identifier. + + Returns: + A :class:`UserLlmSettings` (masked key) or ``None``. + """ + return await self._repo.get(user_id) diff --git a/src/application/use_cases/user_llm_settings/resolve_user_llm_credentials.py b/src/application/use_cases/user_llm_settings/resolve_user_llm_credentials.py new file mode 100644 index 0000000..ee96530 --- /dev/null +++ b/src/application/use_cases/user_llm_settings/resolve_user_llm_credentials.py @@ -0,0 +1,33 @@ +"""Use case: resolve the authenticated user's LLM credentials for the agent factory. + +Returns ``(base_url, api_key_plaintext)`` or ``None`` when the user has not +configured a provider. Used by the DeepAgent factory to build a per-user +:class:`ChatOpenAI` instance. Wraps :meth:`UserLlmSettingsRepository.get_decrypted`. +""" + +import logging + +from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository + +logger = logging.getLogger(__name__) + + +class ResolveUserLlmCredentialsUseCase: + """Resolve the authenticated user's LLM credentials for agent building. + + Depends only on the :class:`UserLlmSettingsRepository` port (DIP). + """ + + def __init__(self, repo: UserLlmSettingsRepository) -> None: + self._repo = repo + + async def execute(self, user_id: str) -> tuple[str, str] | None: + """Return ``(base_url, api_key_plaintext)`` or ``None`` if not configured. + + Args: + user_id: Owner identifier. + + Returns: + A ``(base_url, api_key_plaintext)`` tuple, or ``None``. + """ + return await self._repo.get_decrypted(user_id) diff --git a/src/application/use_cases/user_llm_settings/upsert_user_llm_settings.py b/src/application/use_cases/user_llm_settings/upsert_user_llm_settings.py new file mode 100644 index 0000000..d319aab --- /dev/null +++ b/src/application/use_cases/user_llm_settings/upsert_user_llm_settings.py @@ -0,0 +1,44 @@ +"""Use case: upsert the authenticated user's LLM provider settings. + +Encrypts the API key via :class:`FernetCrypto` before delegating to the +:class:`UserLlmSettingsRepository` port (DIP). Returns the upserted settings +with a masked API key (never the plaintext). +""" + +import logging + +from src.domain.entities.user_llm_settings import UserLlmSettings, UserLlmSettingsInput +from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository +from src.infrastructure.crypto.fernet_crypto import FernetCrypto + +logger = logging.getLogger(__name__) + + +class UpsertUserLlmSettingsUseCase: + """Insert or update the authenticated user's LLM provider settings. + + Depends on the :class:`UserLlmSettingsRepository` port and the + :class:`FernetCrypto` helper (both injected). + """ + + def __init__(self, repo: UserLlmSettingsRepository, crypto: FernetCrypto) -> None: + self._repo = repo + self._crypto = crypto + + async def execute(self, user_id: str, inp: UserLlmSettingsInput) -> UserLlmSettings: + """Encrypt the API key and upsert the user's settings. + + Args: + user_id: Owner identifier. + inp: Input DTO carrying the plaintext API key. + + Returns: + The upserted :class:`UserLlmSettings` (masked key). + """ + api_key_encrypted = self._crypto.encrypt(inp.api_key) + return await self._repo.upsert( + user_id=user_id, + provider=inp.provider, + base_url=inp.base_url, + api_key_encrypted=api_key_encrypted, + ) diff --git a/src/config.py b/src/config.py index 3986d80..9ab2fb1 100644 --- a/src/config.py +++ b/src/config.py @@ -26,6 +26,10 @@ class Settings(BaseSettings): uvicorn_log_level: str = "info" allowed_origins: list[str] = ["http://localhost:8080"] api_key: str = "" + # --- Dual auth (JWT + per-user API keys) --- + logto_url: str = "" + jwt_audience: str = "" + secret_encryption_key: str = "" tracing: TracingSettings = TracingSettings() # Agent execution timeouts (seconds). The per-tool timeout isolates a hung # MCP tool as a recoverable ToolMessage error (agent continues). The graph diff --git a/src/dependencies.py b/src/dependencies.py index 0c1598b..a7441cc 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -6,6 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.pool import AsyncAdaptedQueuePool +from src.application.use_cases.api_key.create_api_key import CreateApiKeyUseCase +from src.application.use_cases.api_key.list_api_keys import ListApiKeysUseCase +from src.application.use_cases.api_key.revoke_api_key import RevokeApiKeyUseCase from src.application.use_cases.create_agent_config import CreateAgentConfigUseCase from src.application.use_cases.create_prompt import CreatePromptUseCase from src.application.use_cases.create_thread import CreateThreadUseCase @@ -29,22 +32,33 @@ from src.application.use_cases.stream_message import StreamMessageUseCase from src.application.use_cases.update_agent_config import UpdateAgentConfigUseCase from src.application.use_cases.update_prompt import UpdatePromptUseCase +from src.application.use_cases.user_llm_settings.delete_user_llm_settings import DeleteUserLlmSettingsUseCase +from src.application.use_cases.user_llm_settings.get_user_llm_settings import GetUserLlmSettingsUseCase +from src.application.use_cases.user_llm_settings.upsert_user_llm_settings import UpsertUserLlmSettingsUseCase from src.config import Settings from src.domain.errors.messages import ErrorMessage +from src.domain.errors.security import AuthenticationError from src.domain.errors.storage import StorageError from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry +from src.domain.ports.auth.api_key_repository import ApiKeyRepository from src.domain.ports.prompt_manager import PromptManager from src.domain.ports.store_file_repository import StoreFileRepository from src.domain.ports.thread_repository import ThreadRepository from src.domain.ports.trace_event_repository import TraceEventRepository from src.domain.ports.tracing_provider import TracingProvider +from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository +from src.infrastructure.auth.jwt_adapter import JwtAdapter +from src.infrastructure.crypto.fernet_crypto import FernetCrypto +from src.infrastructure.database.rls_context import current_user_id from src.infrastructure.mcp.adapter import LangchainMcpToolLoader from src.infrastructure.minio_store.adapter import MinioAgentConfigStore from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry +from src.infrastructure.postgres_api_key.adapter import PostgresApiKeyRepository from src.infrastructure.postgres_repository.adapter import PostgresAgentConfigRepository from src.infrastructure.postgres_thread.adapter import PostgresThreadRepository from src.infrastructure.postgres_trace.adapter import PostgresTraceEventRepository +from src.infrastructure.postgres_user_llm.adapter import PostgresUserLlmSettingsRepository from src.infrastructure.prompt_management.adapter import PhoenixPromptManagerProvider from src.infrastructure.store_file.adapter import LangGraphStoreFileRepository from src.infrastructure.tracing.noop_adapter import NoopTracingProvider @@ -137,6 +151,10 @@ class CompositionRoot: thread_repository: ThreadRepository | None = None trace_event_repository: TraceEventRepository | None = None store_file_repository: StoreFileRepository | None = None + api_key_repository: ApiKeyRepository | None = None + user_llm_settings_repository: UserLlmSettingsRepository | None = None + fernet_crypto: FernetCrypto | None = None + jwt_adapter: JwtAdapter | None = None _root = CompositionRoot() @@ -179,11 +197,46 @@ async def init_persistence() -> None: ) logger.info(LogMessage.SQLALCHEMY_ENGINE_CREATED) + # Register the RLS ``before_cursor_execute`` listener on the engine. The + # listener is a no-op on SQLite (tests) and emits ``SET LOCAL`` GUCs on + # PostgreSQL so Row-Level Security policies can filter rows per user. + from src.infrastructure.database.rls_listener import register_rls_listener + + register_rls_listener(_root.async_engine) + _root.pg_repository = PostgresAgentConfigRepository(engine=_root.async_engine) _root.thread_repository = PostgresThreadRepository(engine=_root.async_engine) _root.trace_event_repository = PostgresTraceEventRepository(engine=_root.async_engine) + _root.api_key_repository = PostgresApiKeyRepository(engine=_root.async_engine) logger.info(LogMessage.POSTGRES_REPOS_INITIALIZED) + # FernetCrypto for per-user LLM API key encryption. If the configured key + # is empty (dev / test), generate a throwaway in-memory key so init does + # not crash — production must set SECRET_ENCRYPTION_KEY. + from cryptography.fernet import Fernet + + fernet_key = settings.secret_encryption_key + if not fernet_key: + logger.warning(LogMessage.LLM_CRYPTO_KEY_EMPTY) + fernet_key = Fernet.generate_key().decode() + _root.fernet_crypto = FernetCrypto(key=fernet_key) + _root.user_llm_settings_repository = PostgresUserLlmSettingsRepository( + engine=_root.async_engine, crypto=_root.fernet_crypto + ) + logger.info(LogMessage.LLM_SETTINGS_REPO_INITIALIZED) + + # Wire the dual-auth AuthService (JWT + per-user API key) into the + # singleton security so verify_credentials can resolve an AuthContext and + # set the RLS contextvars on each authenticated request. + from src.domain.services.auth.auth_service import AuthService + + _root.jwt_adapter = JwtAdapter( + jwks_url=f"{settings.logto_url}/oidc/jwks" if settings.logto_url else "", + audience=settings.jwt_audience, + ) + auth_service = AuthService(jwt_port=_root.jwt_adapter, api_key_repo=_root.api_key_repository) + security.set_auth_service(auth_service) + minio_client = Minio( settings.minio_endpoint, access_key=settings.minio_access_key, @@ -203,23 +256,45 @@ async def init_persistence() -> None: prompt_manager=get_prompt_manager(), stream_idle_timeout=settings.agent_stream_idle_timeout, invoke_timeout=settings.agent_invoke_timeout, + llm_credentials_resolver=get_llm_credentials_resolver(), ) # Store file repository — reuse the singleton LangGraph BaseStore from the # deepagent factory (AsyncPostgresStore) so the file API shares the same # connection pool as the agents. Falls back to the shared InMemoryStore # singleton on init failure. + # + # The namespace is resolved per-request via a ``namespace_provider`` + # callable: ``(user_id, "filesystem")`` when ``current_user_id`` is set + # (authenticated request), ``("filesystem",)`` when it is ``None`` (tests, + # background jobs). This isolates skills/memories per user. try: from src.infrastructure.deepagent.factory import _create_postgres_store + from src.infrastructure.deepagent.namespace import user_namespaced store = await _create_postgres_store(settings) - _root.store_file_repository = LangGraphStoreFileRepository(store=store) + _root.store_file_repository = LangGraphStoreFileRepository( + store=store, namespace_provider=lambda: user_namespaced("filesystem") + ) logger.info(LogMessage.PERSISTENCE_STORE_FILE_INITIALIZED) + + # NOTE: Row-Level Security is NOT applied on the LangGraph ``store`` + # table. ``AsyncPostgresStore`` uses its own asyncpg connection pool + # (not the SQLAlchemy engine), so the RLS listener that sets the + # ``app.user_id`` GUC never runs on store connections. With FORCE RLS + # the policy would filter out every row (GUC is NULL) and reject + # inserts (WITH CHECK fails). Per-user isolation for skills/memories + # is enforced at the application layer via the namespace prefix + # (``user_namespaced`` → ``(user_id, "filesystem")``), which is + # sufficient and works regardless of the connection pool. except Exception: logger.exception(LogMessage.PERSISTENCE_STORE_FILE_INIT_FAILED) from src.infrastructure.deepagent.factory import _get_memory_store + from src.infrastructure.deepagent.namespace import user_namespaced - _root.store_file_repository = LangGraphStoreFileRepository(store=_get_memory_store()) + _root.store_file_repository = LangGraphStoreFileRepository( + store=_get_memory_store(), namespace_provider=lambda: user_namespaced("filesystem") + ) logger.info(LogMessage.PERSISTENCE_STORE_FILE_FALLBACK_INMEMORY) logger.info(LogMessage.PERSISTENCE_REGISTRY_SET) @@ -238,6 +313,11 @@ async def close_persistence() -> None: await _root.async_engine.dispose() logger.info(LogMessage.SQLALCHEMY_ENGINE_DISPOSED) + if _root.jwt_adapter is not None: + await _root.jwt_adapter.close() + _root.jwt_adapter = None + logger.info(LogMessage.JWT_ADAPTER_CLOSED) + def reset() -> None: """Reset all persisted state. Useful for testing.""" @@ -248,6 +328,10 @@ def reset() -> None: _root.thread_repository = None _root.trace_event_repository = None _root.store_file_repository = None + _root.api_key_repository = None + _root.user_llm_settings_repository = None + _root.fernet_crypto = None + _root.jwt_adapter = None logger.info(LogMessage.DEPENDENCIES_INITIALIZED) @@ -366,10 +450,11 @@ def get_delete_agent_config_use_case() -> DeleteAgentConfigUseCase: def get_get_agent_config_use_case() -> GetAgentConfigUseCase: """Provide a GetAgentConfigUseCase instance.""" - store, _ = _require_persistence() + store, repo = _require_persistence() return GetAgentConfigUseCase( config_loader=agent_config_loader, config_store=store, + config_repository=repo, ) @@ -408,11 +493,18 @@ def _require_store_file_repository() -> StoreFileRepository: During tests, ``init_persistence`` is not called, so the repository would be ``None``. To keep the API functional without a database, we lazily create an :class:`InMemoryStore`-backed adapter on first access. + + The fallback uses a per-user ``namespace_provider`` so authenticated + requests are isolated, while unauthenticated contexts (tests with no + ``current_user_id``) fall back to the legacy ``("filesystem",)`` namespace. """ if _root.store_file_repository is None: from src.infrastructure.deepagent.factory import _get_memory_store + from src.infrastructure.deepagent.namespace import user_namespaced - _root.store_file_repository = LangGraphStoreFileRepository(store=_get_memory_store()) + _root.store_file_repository = LangGraphStoreFileRepository( + store=_get_memory_store(), namespace_provider=lambda: user_namespaced("filesystem") + ) return _root.store_file_repository @@ -444,3 +536,122 @@ def get_put_store_file_use_case() -> PutStoreFileUseCase: def get_delete_store_file_use_case() -> DeleteStoreFileUseCase: """Provide a :class:`DeleteStoreFileUseCase` instance.""" return DeleteStoreFileUseCase(_require_store_file_repository()) + + +# ============= API KEY MANAGEMENT PROVIDERS ============= + + +def get_current_user_id() -> str: + """Provide the authenticated user id from the RLS contextvar. + + Set by :meth:`ComposableAgentsSecurity.verify_credentials` after a + successful JWT / API-key authentication. Routes that depend on this + function get a 401 :class:`AuthenticationError` when no user is resolved + (e.g. the dependency is not overridden and no auth middleware ran). + + Returns: + The authenticated user id. + + Raises: + AuthenticationError: If no user id is set in the current context. + """ + user_id = current_user_id.get() + if user_id is None: + raise AuthenticationError(ErrorMessage.AUTH_INVALID_CREDENTIALS) + return user_id + + +def _require_api_key_repository() -> ApiKeyRepository: + """Return the API key repository or raise StorageError if not initialized. + + Returns: + The wired :class:`ApiKeyRepository` instance. + + Raises: + StorageError: If the persistence layer is not initialized. + """ + if _root.api_key_repository is None: + raise StorageError(ErrorMessage.STORAGE_REPO_NOT_INITIALIZED) + return _root.api_key_repository + + +def get_create_api_key_use_case() -> CreateApiKeyUseCase: + """Provide a :class:`CreateApiKeyUseCase` instance.""" + return CreateApiKeyUseCase(repo=_require_api_key_repository()) + + +def get_list_api_keys_use_case() -> ListApiKeysUseCase: + """Provide a :class:`ListApiKeysUseCase` instance.""" + return ListApiKeysUseCase(repo=_require_api_key_repository()) + + +def get_revoke_api_key_use_case() -> RevokeApiKeyUseCase: + """Provide a :class:`RevokeApiKeyUseCase` instance.""" + return RevokeApiKeyUseCase(repo=_require_api_key_repository()) + + +# ============= USER LLM SETTINGS PROVIDERS ============= + + +def _require_user_llm_settings_repository() -> UserLlmSettingsRepository: + """Return the user-LLM-settings repository or raise StorageError if not initialized. + + Returns: + The wired :class:`UserLlmSettingsRepository` instance. + + Raises: + StorageError: If the persistence layer is not initialized. + """ + if _root.user_llm_settings_repository is None: + raise StorageError(ErrorMessage.STORAGE_REPO_NOT_INITIALIZED) + return _root.user_llm_settings_repository + + +def _require_fernet_crypto() -> FernetCrypto: + """Return the FernetCrypto instance or raise StorageError if not initialized. + + Returns: + The wired :class:`FernetCrypto` instance. + + Raises: + StorageError: If the persistence layer is not initialized. + """ + if _root.fernet_crypto is None: + raise StorageError(ErrorMessage.STORAGE_REPO_NOT_INITIALIZED) + return _root.fernet_crypto + + +def get_get_user_llm_settings_use_case() -> GetUserLlmSettingsUseCase: + """Provide a :class:`GetUserLlmSettingsUseCase` instance.""" + return GetUserLlmSettingsUseCase(repo=_require_user_llm_settings_repository()) + + +def get_upsert_user_llm_settings_use_case() -> UpsertUserLlmSettingsUseCase: + """Provide an :class:`UpsertUserLlmSettingsUseCase` instance.""" + return UpsertUserLlmSettingsUseCase( + repo=_require_user_llm_settings_repository(), + crypto=_require_fernet_crypto(), + ) + + +def get_delete_user_llm_settings_use_case() -> DeleteUserLlmSettingsUseCase: + """Provide a :class:`DeleteUserLlmSettingsUseCase` instance.""" + return DeleteUserLlmSettingsUseCase(repo=_require_user_llm_settings_repository()) + + +def get_llm_credentials_resolver(): + """Return a closure resolving the current user's LLM credentials. + + Used by the :class:`PersistentAgentRegistry` to build per-user + :class:`ChatOpenAI` instances. Returns ``None`` when the user has not + configured a provider, so the factory can raise + :class:`LlmNotConfiguredError`. + + Returns: + An async callable ``(user_id: str) -> tuple[str, str] | None``. + """ + + async def _resolve(user_id: str) -> tuple[str, str] | None: + return await _require_user_llm_settings_repository().get_decrypted(user_id) + + return _resolve diff --git a/src/domain/entities/agent_config_metadata.py b/src/domain/entities/agent_config_metadata.py index f03650c..affb971 100644 --- a/src/domain/entities/agent_config_metadata.py +++ b/src/domain/entities/agent_config_metadata.py @@ -12,3 +12,7 @@ class AgentConfigMetadata(BaseModel): created_at: datetime updated_at: datetime description: str | None = None + # Owner of the configuration — set by the repository from the + # ``current_user_id`` contextvar on save. Defaults to ``""`` so existing + # constructions (no auth context) keep working. + user_id: str = "" diff --git a/src/domain/entities/auth/api_key.py b/src/domain/entities/auth/api_key.py new file mode 100644 index 0000000..6ccc7b2 --- /dev/null +++ b/src/domain/entities/auth/api_key.py @@ -0,0 +1,54 @@ +"""Domain entities for per-user API keys. + +``ApiKeyView`` is the safe projection returned by the list endpoint: it never +exposes the hash or the plaintext. ``CreatedApiKey`` is returned once, on +creation, so the caller can display / store the plaintext before it is +discarded (only the SHA-256 hash is persisted). +""" + +from datetime import datetime + +from pydantic import BaseModel + + +class ApiKeyView(BaseModel): + """Safe projection of a stored API key (no hash, no plaintext). + + Attributes: + id: The key identifier (uuid hex). + name: Human-readable label given by the owner. + key_prefix: First 10 chars of the plaintext (``cpk_XXXXX``) used to + recognize the key without revealing it. + created_at: Creation timestamp (UTC). + last_used_at: Last time the key was used to authenticate, or ``None``. + revoked_at: Revocation timestamp, or ``None`` if the key is still + active. + """ + + id: str + name: str + key_prefix: str + created_at: datetime + last_used_at: datetime | None = None + revoked_at: datetime | None = None + + +class CreatedApiKey(BaseModel): + """Result of creating a new API key. + + The ``plaintext`` is returned exactly once so the caller can display / store + it; it is never persisted (only its SHA-256 hash is). + + Attributes: + id: The key identifier (uuid hex). + name: Human-readable label given by the owner. + key_prefix: First 10 chars of the plaintext. + plaintext: The full generated key (``cpk_...``). Shown once. + created_at: Creation timestamp (UTC). + """ + + id: str + name: str + key_prefix: str + plaintext: str + created_at: datetime diff --git a/src/domain/entities/auth/auth_context.py b/src/domain/entities/auth/auth_context.py new file mode 100644 index 0000000..b5c8e32 --- /dev/null +++ b/src/domain/entities/auth/auth_context.py @@ -0,0 +1,27 @@ +"""AuthContext domain entity. + +The result of a successful authentication. Carries the resolved user +identifier, the authentication method that produced it and the raw credential +string (JWT token value or API key) for downstream RLS / audit wiring. +""" + +from typing import Literal + +from pydantic import BaseModel + + +class AuthContext(BaseModel): + """Result of authenticating an incoming request. + + Attributes: + user_id: Identifier of the authenticated principal (JWT ``sub`` or the + user_id returned by the API key repository). + method: Authentication method that produced this context. + raw_credential: The raw credential value as received (JWT token without + the ``Bearer `` prefix, or the API key plaintext). Used to set the + RLS contextvar for audit / row-level security. + """ + + user_id: str + method: Literal["jwt", "api_key"] + raw_credential: str diff --git a/src/domain/entities/thread.py b/src/domain/entities/thread.py index 6984312..147d56d 100644 --- a/src/domain/entities/thread.py +++ b/src/domain/entities/thread.py @@ -22,6 +22,11 @@ class Thread(BaseModel): trace_events: list[TraceEvent] = Field(default_factory=list) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + # Owner of the thread — set by the repository from the ``current_user_id`` + # contextvar on create. Defaults to ``""`` so existing constructions (no + # auth context, pre-auth-core tests) keep working and the row is invisible + # under RLS but visible in SQLite tests (no RLS policies). + user_id: str = "" @computed_field # type: ignore[prop-decorator] @property diff --git a/src/domain/entities/trace_event.py b/src/domain/entities/trace_event.py index cf6f2ad..054b9be 100644 --- a/src/domain/entities/trace_event.py +++ b/src/domain/entities/trace_event.py @@ -53,3 +53,7 @@ class TraceEvent(BaseModel, frozen=True): metadata: dict | None = None timestamp: datetime sequence: int = Field(ge=0) + # Owner of the event (denormalized from the parent thread). Defaults to + # ``""`` so existing constructions keep working; the repository sets it + # from ``current_user_id`` on add/add_batch. + user_id: str = "" diff --git a/src/domain/entities/user/user.py b/src/domain/entities/user/user.py new file mode 100644 index 0000000..0c1bad6 --- /dev/null +++ b/src/domain/entities/user/user.py @@ -0,0 +1,27 @@ +"""User domain entity. + +Represents the authenticated principal resolved from a JWT bearer token. Only +the minimal fields required by the auth-core layer are modelled here; the +upstream IdP (Logto) payload may contain many more claims which Pydantic +silently ignores thanks to ``extra="ignore"``. +""" + +from pydantic import BaseModel, ConfigDict + + +class User(BaseModel): + """Authenticated user resolved from a JWT payload. + + Attributes: + sub: Subject identifier (JWT ``sub`` claim) — the only required field. + email: User email (optional — may be absent for service accounts). + name: User full name (optional). + username: Username (optional). + """ + + model_config = ConfigDict(extra="ignore") + + sub: str + email: str | None = None + name: str | None = None + username: str | None = None diff --git a/src/domain/entities/user_llm_settings.py b/src/domain/entities/user_llm_settings.py new file mode 100644 index 0000000..c7a6447 --- /dev/null +++ b/src/domain/entities/user_llm_settings.py @@ -0,0 +1,42 @@ +"""Domain entity for per-user LLM provider settings. + +A user configures their own OpenAI-compatible LLM provider (provider label, +base URL, API key). The API key is stored encrypted at rest (Fernet) and never +returned in plaintext by any GET endpoint — only a masked preview is exposed. + +Attributes: + user_id: Owner identifier (from the auth context). + provider: Free-form label, e.g. "openai", "openrouter" (display only). + base_url: OpenAI-compatible base URL used by the agent factory. + api_key_masked: Masked preview of the API key (never the full key) — + ``None`` when no settings exist. + created_at: Creation timestamp (UTC). + updated_at: Last update timestamp (UTC). +""" + +from datetime import datetime + +from pydantic import BaseModel + + +class UserLlmSettings(BaseModel): + """Per-user LLM provider settings (safe projection — no plaintext key).""" + + user_id: str + provider: str + base_url: str + api_key_masked: str | None = None + created_at: datetime + updated_at: datetime + + +class UserLlmSettingsInput(BaseModel): + """Input DTO for an upsert (PUT) operation. + + The ``api_key`` is plaintext on the wire (HTTPS) and stored encrypted at + rest by the use case before persistence. + """ + + provider: str + base_url: str + api_key: str diff --git a/src/domain/errors/llm.py b/src/domain/errors/llm.py new file mode 100644 index 0000000..b291f1e --- /dev/null +++ b/src/domain/errors/llm.py @@ -0,0 +1,24 @@ +"""LLM-related domain errors. + +Raised when a user tries to run an agent without configuring an LLM provider. +""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class LlmError(DomainError): + """Base error for LLM configuration concerns.""" + + status_code = ErrorCode.UNPROCESSABLE_ENTITY + + +class LlmNotConfiguredError(LlmError): + """Raised when no LLM provider is configured for the authenticated user. + + Mapped to HTTP 422 by the generic domain-error handler. The user must + configure their provider via ``PUT /api/v1/settings/llm`` before invoking + any agent. + """ + + status_code = ErrorCode.UNPROCESSABLE_ENTITY diff --git a/src/domain/errors/messages.py b/src/domain/errors/messages.py index d2f4672..4e5f360 100644 --- a/src/domain/errors/messages.py +++ b/src/domain/errors/messages.py @@ -73,6 +73,7 @@ class ErrorMessage(StrEnum): STORAGE_FAILED_DELETE_AGENT_CONFIG = "Failed to delete agent config metadata '{name}': {error}" STORAGE_FAILED_EXISTS_AGENT_CONFIG = "Failed to check existence of agent config '{name}': {error}" STORAGE_FAILED_PERSIST_STREAM = "Failed to persist AI message after stream: {error}" + STORAGE_FAILED_API_KEY_OP = "Failed to perform API key operation: {error}" # --- HITL --- INVALID_HITL_ACTION = "Unsupported HITL action: {action}" @@ -93,6 +94,17 @@ class ErrorMessage(StrEnum): API_KEY_UNAUTHORIZED = "The Api Key you provided is unauthorized" API_KEY_EMPTY = "The Api Key is empty" API_KEY_DISABLED = "Auth by Api key is disabled" + API_KEY_NOT_FOUND = "API key not found: {key_id}" + API_KEY_NAME_REQUIRED = "API key name is required" + + # --- Auth (dual JWT / API key) --- + AUTH_INVALID_CREDENTIALS = "Invalid or missing credentials" + AUTH_JWT_INVALID = "Invalid JWT token" + AUTH_API_KEY_INVALID = "Invalid API key" + + # --- LLM credentials (per user) --- + LLM_NOT_CONFIGURED = "No LLM provider configured for user {user_id}. Configure via PUT /api/v1/settings/llm." + LLM_SETTINGS_NOT_FOUND = "LLM settings not found for user {user_id}" def tmpl(template: str) -> Template: diff --git a/src/domain/errors/security.py b/src/domain/errors/security.py index 42d3248..4ff809a 100644 --- a/src/domain/errors/security.py +++ b/src/domain/errors/security.py @@ -12,3 +12,30 @@ class InvalidApiKeyError(SecurityError): """Error when api key sent by client is not matching""" status_code = ErrorCode.UNAUTHORIZED + + +class AuthenticationError(SecurityError): + """Raised when no valid credentials (JWT or API key) could be resolved. + + Mapped to HTTP 401 by the generic domain-error handler. + """ + + status_code = ErrorCode.UNAUTHORIZED + + +class ApiKeyError(SecurityError): + """Raised for invalid API-key management input (e.g. empty name). + + Mapped to HTTP 422 by the generic domain-error handler. + """ + + status_code = ErrorCode.UNPROCESSABLE_ENTITY + + +class ApiKeyNotFoundError(SecurityError): + """Raised when an API key does not exist (or is owned by another user). + + Mapped to HTTP 404 by the generic domain-error handler. + """ + + status_code = ErrorCode.NOT_FOUND diff --git a/src/domain/logging/messages.py b/src/domain/logging/messages.py index 02183df..e4e5325 100644 --- a/src/domain/logging/messages.py +++ b/src/domain/logging/messages.py @@ -46,6 +46,7 @@ class LogMessage(StrEnum): PERSISTENCE_REGISTRY_SET = "Persistence layer initialized, agent_registry set to PersistentAgentRegistry" PERSISTENT_REGISTRY_CLOSED = "Persistent registry closed" SQLALCHEMY_ENGINE_DISPOSED = "SQLAlchemy engine disposed" + JWT_ADAPTER_CLOSED = "JWT adapter httpx client closed" PERSISTENCE_STORE_FILE_INITIALIZED = "Store file repository initialized (AsyncPostgresStore)" PERSISTENCE_STORE_FILE_INIT_FAILED = ( "Failed to initialize store file repository with Postgres, falling back to InMemoryStore" @@ -259,3 +260,28 @@ class LogMessage(StrEnum): # --- Security --- LOG_INVALID_API_KEY = "Invalid API key: %s" + + # --- Auth (dual JWT / API key) --- + # The enum value is a stable prefix kept verbatim in the formatted log line + # (no ``%s`` inside it) so tests can assert ``LogMessage.X in r.getMessage()``. + # No PII is interpolated here — only error type / message for diagnostics. + AUTH_JWT_DECODE_FAILED = "JWT decode failed" + AUTH_JWKS_FETCH_FAILED = "JWKS fetch failed" + AUTH_CREDENTIALS_VALIDATED = "Credentials validated for user_id=%s method=%s" + + # --- API key management (per-user) --- + API_KEY_CREATED = "API key created: id=%s user_id=%s" + API_KEY_REVOKED = "API key revoked: id=%s user_id=%s" + API_KEY_LISTED = "Listed %d API keys for user_id=%s" + + # --- RLS (Row-Level Security) --- + RLS_CONTEXT_SET = "RLS context set: app.user_id=%s" + RLS_BYPASS_ENABLED = "RLS bypass enabled (row_security=off)" + + # --- LLM settings (per user) --- + LLM_SETTINGS_DECRYPT_FAILED = "Failed to decrypt LLM API key for user_id=%s; returning masked=None" + LLM_SETTINGS_REPO_INITIALIZED = "User LLM settings repository initialized" + LLM_CRYPTO_KEY_EMPTY = ( + "SECRET_ENCRYPTION_KEY is empty — generated a throwaway in-memory Fernet key. " + "Set SECRET_ENCRYPTION_KEY in production to persist encrypted API keys across restarts." + ) diff --git a/src/domain/ports/auth/api_key_repository.py b/src/domain/ports/auth/api_key_repository.py new file mode 100644 index 0000000..322bfc3 --- /dev/null +++ b/src/domain/ports/auth/api_key_repository.py @@ -0,0 +1,90 @@ +"""Port for the API-key repository (outbound boundary). + +Implemented by :class:`~src.infrastructure.postgres_api_key.adapter.PostgresApiKeyRepository` +against the ``api_keys`` table. The auth service only depends on this +abstraction, and the API-key management use cases depend on the management +methods added in this extended port. +""" + +from abc import ABC, abstractmethod + +from src.domain.entities.auth.api_key import ApiKeyView + + +class ApiKeyRepository(ABC): + """Outbound port: persist and look up per-user API keys. + + API keys are stored hashed (SHA-256 hex of the plaintext) so lookups are + performed on the hash, never on the plaintext. An active key is one that is + not revoked (``revoked_at IS NULL``). + """ + + @abstractmethod + async def find_active_by_hash(self, key_hash: str) -> tuple[str, str] | None: + """Return ``(user_id, key_id)`` for the active key matching ``key_hash``. + + Args: + key_hash: The SHA-256 hex digest of the API key plaintext. + + Returns: + A ``(user_id, key_id)`` tuple if an active key matches, else ``None``. + """ + ... + + @abstractmethod + async def create(self, user_id: str, name: str, key_hash: str, key_prefix: str) -> str: + """Persist a new API key and return its generated id. + + Args: + user_id: Owner of the key. + name: Human-readable label. + key_hash: SHA-256 hex digest of the plaintext (never the plaintext). + key_prefix: First 10 chars of the plaintext (for recognition). + + Returns: + The generated key id (uuid hex). + """ + ... + + @abstractmethod + async def list_by_user(self, user_id: str) -> list[ApiKeyView]: + """Return all API keys owned by ``user_id`` (active and revoked). + + Results are ordered by ``created_at`` descending (newest first). The + hash is never included in the returned views. + + Args: + user_id: Owner whose keys are returned. + + Returns: + A list of :class:`ApiKeyView` (possibly empty). + """ + ... + + @abstractmethod + async def revoke(self, user_id: str, key_id: str) -> None: + """Revoke the key ``key_id`` owned by ``user_id``. + + Idempotent: revoking an already-revoked key is a no-op success. + + Args: + user_id: Owner of the key (a key owned by another user is treated + as not found). + key_id: Id of the key to revoke. + + Raises: + ApiKeyNotFoundError: If no key matches ``(user_id, key_id)``. + """ + ... + + @abstractmethod + async def touch_last_used(self, key_id: str) -> None: + """Update ``last_used_at`` to now for ``key_id``. + + Silent no-op if the key does not exist (used on the auth hot path where + a stale/revoked key may still be presented). + + Args: + key_id: Id of the key that was just used. + """ + ... diff --git a/src/domain/ports/auth/jwt_service.py b/src/domain/ports/auth/jwt_service.py new file mode 100644 index 0000000..d46678c --- /dev/null +++ b/src/domain/ports/auth/jwt_service.py @@ -0,0 +1,33 @@ +"""Port for JWT token verification (outbound boundary). + +The application layer depends on this abstraction; the concrete ``JwtAdapter`` +(infra) implements it against a remote JWKS endpoint. +""" + +from abc import ABC, abstractmethod + +from src.domain.entities.user.user import User + + +class JwtServicePort(ABC): + """Outbound port: decode and verify a JWT bearer token. + + Implementations MUST be async (the JWKS endpoint is fetched over HTTP) and + MUST return ``None`` (never raise) when the token is invalid, expired or the + JWKS endpoint is unreachable — so the caller can fall through to other auth + methods or reject the request with a clean 401. + """ + + @abstractmethod + async def decode_token(self, token: str) -> User | None: + """Verify ``token`` and return the resolved :class:`User`, or ``None``. + + Args: + token: The raw JWT string (without the ``Bearer `` prefix). + + Returns: + The authenticated :class:`User` on success, or ``None`` on any + verification failure (expired, invalid signature, bad audience, + unreachable JWKS endpoint, malformed payload, …). + """ + ... diff --git a/src/domain/ports/user_llm_settings_repository.py b/src/domain/ports/user_llm_settings_repository.py new file mode 100644 index 0000000..a8c23a2 --- /dev/null +++ b/src/domain/ports/user_llm_settings_repository.py @@ -0,0 +1,73 @@ +"""Outbound port: persist and resolve per-user LLM provider settings. + +Implemented by :class:`~src.infrastructure.postgres_user_llm.adapter.PostgresUserLlmSettingsRepository` +against the ``user_llm_settings`` table. The agent factory depends on +:meth:`get_decrypted` to resolve the ``(base_url, api_key)`` tuple needed to +build a per-user ``ChatOpenAI`` instance. +""" + +from abc import ABC, abstractmethod + +from src.domain.entities.user_llm_settings import UserLlmSettings + + +class UserLlmSettingsRepository(ABC): + """Outbound port: persist per-user LLM provider settings.""" + + @abstractmethod + async def get(self, user_id: str) -> UserLlmSettings | None: + """Return the user's settings (masked key), or ``None`` if not configured. + + Args: + user_id: Owner identifier. + + Returns: + A :class:`UserLlmSettings` with ``api_key_masked`` (never the full + key), or ``None`` when the user has no configured provider. + """ + ... + + @abstractmethod + async def upsert( + self, + user_id: str, + provider: str, + base_url: str, + api_key_encrypted: str, + ) -> UserLlmSettings: + """Insert or update the user's settings (encrypted API key). + + Args: + user_id: Owner identifier. + provider: Free-form provider label. + base_url: OpenAI-compatible base URL. + api_key_encrypted: Fernet-encrypted API key token. + + Returns: + The upserted :class:`UserLlmSettings` (masked key). + """ + ... + + @abstractmethod + async def delete(self, user_id: str) -> None: + """Delete the user's settings. No-op when absent. + + Args: + user_id: Owner identifier. + """ + ... + + @abstractmethod + async def get_decrypted(self, user_id: str) -> tuple[str, str] | None: + """Return ``(base_url, api_key_plaintext)`` for the agent factory. + + The plaintext is decrypted on demand (per-request). Returns ``None`` + when the user has no configured provider. + + Args: + user_id: Owner identifier. + + Returns: + A ``(base_url, api_key_plaintext)`` tuple, or ``None``. + """ + ... diff --git a/src/domain/services/auth/api_key_hasher.py b/src/domain/services/auth/api_key_hasher.py new file mode 100644 index 0000000..2ee03be --- /dev/null +++ b/src/domain/services/auth/api_key_hasher.py @@ -0,0 +1,39 @@ +"""API key hashing / generation utility (domain logic). + +Pure helper (no port, no I/O) used by the auth domain service to hash incoming +API keys before looking them up in the repository, and to generate new +plaintext keys with the ``cpk_`` prefix. + +Hashing uses SHA-256 (deterministic, fast, sufficient for a high-entropy +secret — bcrypt would be overkill and slower on every request). This lives in +the domain layer because the hashing policy is part of the authentication +business rules, not an infrastructure concern. +""" + +import hashlib +import secrets + + +class ApiKeyHasher: + """Hash and generate Composable Agents API keys (``cpk_`` prefixed).""" + + @staticmethod + def hash_key(plaintext: str) -> str: + """Return the SHA-256 hex digest of ``plaintext``. + + Args: + plaintext: The raw API key (e.g. ``cpk_xxx``). + + Returns: + The 64-char lowercase hex digest. + """ + return hashlib.sha256(plaintext.encode()).hexdigest() + + @staticmethod + def generate_key() -> str: + """Generate a new random API key with the ``cpk_`` prefix. + + Returns: + A string of the form ``cpk_`` + 32+ url-safe base64 chars. + """ + return "cpk_" + secrets.token_urlsafe(32) diff --git a/src/domain/services/auth/auth_service.py b/src/domain/services/auth/auth_service.py new file mode 100644 index 0000000..d150732 --- /dev/null +++ b/src/domain/services/auth/auth_service.py @@ -0,0 +1,95 @@ +"""Auth domain service — orchestrates dual authentication. + +The only component that knows the precedence rules between JWT bearer tokens +and per-user API keys. Depends on two ports (no concrete adapters): +``JwtServicePort`` (JWT verification) and ``ApiKeyRepository`` (API key +lookup). API keys are hashed (SHA-256) before lookup — plaintext is never +sent to the repository. + +Precedence: JWT bearer token first; only when no valid JWT is present does the +service fall back to the API key path. When neither yields a context, the +service returns ``None`` and the caller (the FastAPI dependency) raises +``AuthenticationError``. +""" + +import logging + +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.entities.user.user import User +from src.domain.logging.messages import LogMessage +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.domain.ports.auth.jwt_service import JwtServicePort +from src.domain.services.auth.api_key_hasher import ApiKeyHasher + +logger = logging.getLogger(__name__) + +_BEARER_PREFIX = "Bearer " + + +class AuthService: + """Resolve an :class:`AuthContext` from a request's credentials. + + Args: + jwt_port: Outbound port used to verify JWT bearer tokens. + api_key_repo: Outbound port used to look up active API keys by hash. + """ + + def __init__(self, jwt_port: JwtServicePort, api_key_repo: ApiKeyRepository) -> None: + self._jwt_port = jwt_port + self._api_key_repo = api_key_repo + + async def authenticate( + self, + authorization: str | None, + api_key: str | None, + ) -> AuthContext | None: + """Authenticate the request and return an :class:`AuthContext` or ``None``. + + Args: + authorization: Raw value of the ``Authorization`` header (``None`` + if absent). Only the ``Bearer`` scheme is recognised. + api_key: Raw value of the ``X-API-Key`` header (``None`` if absent). + + Returns: + An :class:`AuthContext` on success, or ``None`` when no credential + could be validated. + + Note: + JWT takes precedence over API key: when a valid ``Bearer`` token is + present the API key is never consulted. + """ + # 1. JWT path — takes precedence. + if authorization and authorization.startswith(_BEARER_PREFIX): + token = authorization[len(_BEARER_PREFIX) :] + user: User | None = await self._jwt_port.decode_token(token) + if user is not None: + logger.info( + LogMessage.AUTH_CREDENTIALS_VALIDATED, + user.sub, + "jwt", + ) + return AuthContext(user_id=user.sub, method="jwt", raw_credential=token) + # Invalid JWT → no fallback to API key (matches the test contract). + return None + + # 2. API key path — only when no Bearer token was provided. + if api_key: + key_hash = ApiKeyHasher.hash_key(api_key) + found = await self._api_key_repo.find_active_by_hash(key_hash) + if found is not None: + user_id, key_id = found + # Best-effort last-used tracking; never block auth on this. + try: + await self._api_key_repo.touch_last_used(key_id) + except Exception: + logger.debug("Failed to update last_used_at for api key %s", key_id) + logger.info( + LogMessage.AUTH_CREDENTIALS_VALIDATED, + user_id, + "api_key", + ) + return AuthContext(user_id=user_id, method="api_key", raw_credential=api_key) + return None + + # 3. No credentials at all. + return None diff --git a/src/infrastructure/auth/api_key_hasher.py b/src/infrastructure/auth/api_key_hasher.py new file mode 100644 index 0000000..6cee030 --- /dev/null +++ b/src/infrastructure/auth/api_key_hasher.py @@ -0,0 +1,10 @@ +"""Backward-compat re-export of :class:`ApiKeyHasher`. + +The implementation now lives in :mod:`src.domain.services.auth.api_key_hasher` +(hash policy is domain logic). This thin re-export keeps existing imports +working; new code should import from the domain module directly. +""" + +from src.domain.services.auth.api_key_hasher import ApiKeyHasher + +__all__ = ["ApiKeyHasher"] diff --git a/src/infrastructure/auth/jwt_adapter.py b/src/infrastructure/auth/jwt_adapter.py new file mode 100644 index 0000000..fa104ce --- /dev/null +++ b/src/infrastructure/auth/jwt_adapter.py @@ -0,0 +1,183 @@ +"""JWT adapter — verifies bearer tokens against a remote JWKS endpoint. + +Mirrors the pickpro-back JWT adapter pattern, but uses an in-memory +``cachetools.TTLCache`` (single entry, TTL 300s) instead of Valkey. The JWKS +document is fetched via ``httpx.AsyncClient`` and cached; individual signing +keys are reconstructed from the cached JWKS without any network call. + +On ANY decode error (expired, invalid signature, bad audience, unreachable +JWKS endpoint, malformed payload, …) the adapter returns ``None`` and logs a +warning — it NEVER raises, so the caller (``AuthService``) can fall through to +other auth methods or reject the request with a clean 401. + +No PII is ever logged: only the error type and message are interpolated into +the failure log line. +""" + +import asyncio +import logging + +import httpx +import jwt +from cachetools import TTLCache +from jwt import PyJWK, PyJWKClientConnectionError +from jwt.exceptions import PyJWKClientError + +from src.domain.entities.user.user import User +from src.domain.logging.messages import LogMessage +from src.domain.ports.auth.jwt_service import JwtServicePort + +logger = logging.getLogger(__name__) + +# Algorithms accepted for verification. No issuer validation is performed. +_ACCEPTED_ALGORITHMS = ["RS256", "ES256", "ES384"] + + +class JwtAdapter(JwtServicePort): + """Verify JWT bearer tokens against a JWKS endpoint with in-memory caching. + + The JWKS document is fetched lazily on the first ``decode_token`` call and + cached in a ``cachetools.TTLCache`` (single entry, TTL 300s). An + ``asyncio.Lock`` guards the fetch to prevent a stampede of concurrent + decodes all hitting the JWKS endpoint on a cold cache. + + Args: + jwks_url: URL of the OIDC JWKS endpoint. When empty, ``decode_token`` + always returns ``None`` (no JWKS configured). + audience: Expected JWT ``aud`` claim. Passed to ``jwt.decode``. + """ + + _JWKS_CACHE_TTL = 300 + _JWKS_CACHE_KEY = "jwks" + + def __init__(self, jwks_url: str, audience: str) -> None: + self._jwks_url = jwks_url + self._audience = audience + # The HTTP client is only needed when a JWKS endpoint is configured; + # the cache + lock are cheap and always present so the rest of the + # code does not need to branch on ``jwks_url``. + self._jwks_http_client: httpx.AsyncClient | None = ( + httpx.AsyncClient(timeout=30.0, headers={"User-Agent": "composable-agents/1.0"}) if jwks_url else None + ) + self._jwks_cache: TTLCache = TTLCache(maxsize=1, ttl=self._JWKS_CACHE_TTL) + self._jwks_lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # JWKS fetching / caching + # ------------------------------------------------------------------ + + async def _fetch_jwks(self) -> dict: + """Fetch the JWKS document from the endpoint as a dict. + + Raises: + httpx.HTTPError: on any transport / HTTP error (caller logs + None). + PyJWKClientConnectionError: on a JWKS client connection failure. + """ + response = await self._jwks_http_client.get(self._jwks_url) # type: ignore[union-attr] + response.raise_for_status() + return response.json() + + async def _get_cached_jwks(self) -> dict: + """Return the cached JWKS, fetching it on a cache miss. + + An ``asyncio.Lock`` serialises concurrent cold-cache fetches so only one + HTTP call is made even under burst load. On fetch failure nothing is + cached (the next call will retry). + """ + cached = self._jwks_cache.get(self._JWKS_CACHE_KEY) + if cached is not None: + return cached + async with self._jwks_lock: + # Re-check inside the lock — another task may have populated it. + cached = self._jwks_cache.get(self._JWKS_CACHE_KEY) + if cached is not None: + return cached + jwks = await self._fetch_jwks() + self._jwks_cache[self._JWKS_CACHE_KEY] = jwks + return jwks + + # ------------------------------------------------------------------ + # Token / key helpers + # ------------------------------------------------------------------ + + @staticmethod + def _extract_kid(token: str) -> str | None: + """Extract the ``kid`` from the JWT header without verifying signature.""" + header = jwt.get_unverified_header(token) + return header.get("kid") + + @staticmethod + def _find_key_by_kid(jwks: dict, kid: str | None) -> dict: + """Find the JWK with the given ``kid`` in the JWKS document. + + Falls back to the first key when ``kid`` is ``None`` or no match is + found (single-key JWKS). Raises ``PyJWKClientError`` when the JWKS is + empty. + """ + keys = jwks.get("keys", []) + if kid is not None: + for key in keys: + if key.get("kid") == kid: + return key + if keys: + return keys[0] + raise PyJWKClientError(f"Unable to find a signing key that matches: '{kid}'") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def decode_token(self, token: str) -> User | None: + """Verify ``token`` and return the resolved :class:`User`, or ``None``. + + Args: + token: The raw JWT string (without the ``Bearer `` prefix). + + Returns: + The authenticated :class:`User` on success, or ``None`` on any + verification failure. Never raises. + """ + if self._jwks_http_client is None: + # No JWKS configured — cannot verify anything. + return None + + try: + # 1. Fetch (cached) JWKS — done first so a JWKS endpoint outage is + # reported as AUTH_JWKS_FETCH_FAILED rather than masked by a + # header-decode error on an opaque token. + jwks = await self._get_cached_jwks() + + # 2. Resolve the signing key from the JWKS by kid. + kid = self._extract_kid(token) + key_dict = self._find_key_by_kid(jwks, kid) + signing_key = PyJWK.from_dict(key_dict) + + # 3. Verify + decode. + payload = jwt.decode( + token, + key=signing_key.key, + algorithms=_ACCEPTED_ALGORITHMS, + audience=self._audience, + ) + + return User.model_validate(payload) + except (httpx.HTTPError, PyJWKClientConnectionError) as e: + # Both signal a JWKS endpoint outage / transport failure. + logger.error("%s: %s", LogMessage.AUTH_JWKS_FETCH_FAILED, e) + return None + except (jwt.ExpiredSignatureError, jwt.InvalidAlgorithmError, jwt.PyJWTError) as e: + # PyJWKClientError is a subclass of PyJWTError and is caught here. + # The enum value is a stable prefix kept verbatim in the formatted + # line so tests can assert membership without PII leakage. + logger.warning("%s: %s: %s", LogMessage.AUTH_JWT_DECODE_FAILED, type(e).__name__, e) + return None + except ValueError as e: + # Pydantic ValidationError is a ValueError subclass — covers + # malformed claims (e.g. missing ``sub``) without leaking PII. + logger.warning("%s: %s: %s", LogMessage.AUTH_JWT_DECODE_FAILED, type(e).__name__, e) + return None + + async def close(self) -> None: + """Close the internal HTTP client if one was created.""" + if self._jwks_http_client is not None: + await self._jwks_http_client.aclose() diff --git a/src/infrastructure/crypto/fernet_crypto.py b/src/infrastructure/crypto/fernet_crypto.py new file mode 100644 index 0000000..c9bb5c7 --- /dev/null +++ b/src/infrastructure/crypto/fernet_crypto.py @@ -0,0 +1,57 @@ +"""Fernet-based symmetric encryption for per-user LLM API keys. + +Wraps :class:`cryptography.fernet.Fernet` behind a small, testable facade. The +key is provided by ``Settings.secret_encryption_key`` (a URL-safe base64 +Fernet key). An empty / whitespace-only key raises :class:`ValueError` at +construction so wiring fails fast in production (tests pass a fixed key). +""" + +from cryptography.fernet import Fernet + + +class FernetCrypto: + """Symmetric encrypt / decrypt helper for at-rest API key storage. + + Attributes: + _fernet: The underlying :class:`Fernet` instance. + """ + + def __init__(self, key: str) -> None: + """Initialize the Fernet cipher with a URL-safe base64 key. + + Args: + key: A URL-safe base64 Fernet key (32 bytes encoded). Must be + non-empty. + + Raises: + ValueError: If ``key`` is empty or whitespace-only. + """ + if not key or not key.strip(): + raise ValueError("FernetCrypto requires a non-empty key") + self._fernet = Fernet(key.encode()) + + def encrypt(self, plaintext: str) -> str: + """Encrypt ``plaintext`` and return a URL-safe base64 token string. + + Args: + plaintext: The API key plaintext. + + Returns: + The Fernet token (str), which includes the IV + timestamp. + """ + return self._fernet.encrypt(plaintext.encode()).decode() + + def decrypt(self, token: str) -> str: + """Decrypt a Fernet token and return the plaintext. + + Args: + token: The Fernet token produced by :meth:`encrypt`. + + Returns: + The original plaintext. + + Raises: + cryptography.fernet.InvalidToken: If the token is tampered or was + encrypted with a different key. + """ + return self._fernet.decrypt(token.encode()).decode() diff --git a/src/infrastructure/database/models/agent_config.py b/src/infrastructure/database/models/agent_config.py index 2983e9c..2edeb1c 100644 --- a/src/infrastructure/database/models/agent_config.py +++ b/src/infrastructure/database/models/agent_config.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import DateTime, String +from sqlalchemy import DateTime, Index, String from sqlalchemy.orm import Mapped, mapped_column from src.infrastructure.database.models.base import Base @@ -8,6 +8,7 @@ class AgentConfigModel(Base): __tablename__ = "agent_configs" + __table_args__ = (Index("ix_agent_configs_user_id", "user_id"),) name: Mapped[str] = mapped_column(String(100), primary_key=True) model: Mapped[str] = mapped_column(String(200), nullable=False) @@ -15,3 +16,5 @@ class AgentConfigModel(Base): description: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # Owner of the configuration — NOT NULL with default '' (RLS plumbing). + user_id: Mapped[str] = mapped_column(String(255), nullable=False, server_default="", default="") diff --git a/src/infrastructure/database/models/api_key.py b/src/infrastructure/database/models/api_key.py new file mode 100644 index 0000000..82cd3a7 --- /dev/null +++ b/src/infrastructure/database/models/api_key.py @@ -0,0 +1,44 @@ +"""SQLAlchemy ORM model for the ``api_keys`` table. + +Per-user API keys are stored hashed (SHA-256 hex of the plaintext). The +``key_hash`` column is unique and indexed for fast lookup on the auth hot +path; ``user_id`` is indexed for the list-by-user query. ``revoked_at`` is +``NULL`` for an active key. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, Index, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base + + +class ApiKeyModel(Base): + """ORM model for a per-user API key row. + + Attributes: + id: uuid hex primary key. + user_id: Owner identifier (indexed). + name: Human-readable label. + key_hash: SHA-256 hex digest of the plaintext (unique, indexed). + key_prefix: First 10 chars of the plaintext (for recognition). + revoked_at: Revocation timestamp, or ``None`` if active. + last_used_at: Last use timestamp, or ``None`` if never used. + created_at: Creation timestamp (UTC, non-null). + """ + + __tablename__ = "api_keys" + __table_args__ = ( + Index("ix_api_keys_user_id", "user_id"), + Index("ix_api_keys_key_hash", "key_hash", unique=True), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + key_hash: Mapped[str] = mapped_column(String(64), nullable=False) + key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/src/infrastructure/database/models/thread.py b/src/infrastructure/database/models/thread.py index 27dd800..1526bae 100644 --- a/src/infrastructure/database/models/thread.py +++ b/src/infrastructure/database/models/thread.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, String +from sqlalchemy import DateTime, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.infrastructure.database.models.base import Base @@ -12,11 +12,15 @@ class ThreadModel(Base): __tablename__ = "threads" + __table_args__ = (Index("ix_threads_user_id", "user_id"),) id: Mapped[str] = mapped_column(String(36), primary_key=True) agent_name: Mapped[str] = mapped_column(String(100), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # Owner of the thread — NOT NULL with default '' so existing rows become + # user_id='' (invisible under RLS but still visible in SQLite tests). + user_id: Mapped[str] = mapped_column(String(255), nullable=False, server_default="", default="") # lazy="raise" prevents silent N+1 queries. Always load trace_events # explicitly via trace_repo.list_by_thread(thread_id) or selectinload. diff --git a/src/infrastructure/database/models/trace_event.py b/src/infrastructure/database/models/trace_event.py index 34ba2a6..ddfa851 100644 --- a/src/infrastructure/database/models/trace_event.py +++ b/src/infrastructure/database/models/trace_event.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from src.infrastructure.database.models.base import Base @@ -21,6 +21,7 @@ class TraceEventModel(Base): """ __tablename__ = "trace_events" + __table_args__ = (Index("ix_trace_events_user_id", "user_id"),) id: Mapped[str] = mapped_column(String(36), primary_key=True) thread_id: Mapped[str] = mapped_column( @@ -38,3 +39,6 @@ class TraceEventModel(Base): sequence: Mapped[int] = mapped_column(Integer, nullable=False) thread: Mapped["ThreadModel"] = relationship("ThreadModel", back_populates="trace_events") + # Owner of the event (denormalized from the parent thread for direct + # filtering without a JOIN). NOT NULL with default '' (RLS plumbing). + user_id: Mapped[str] = mapped_column(String(255), nullable=False, server_default="", default="") diff --git a/src/infrastructure/database/models/user_llm_setting.py b/src/infrastructure/database/models/user_llm_setting.py new file mode 100644 index 0000000..38ab76b --- /dev/null +++ b/src/infrastructure/database/models/user_llm_setting.py @@ -0,0 +1,35 @@ +"""SQLAlchemy ORM model for the ``user_llm_settings`` table. + +Per-user LLM provider settings. The API key is stored encrypted (Fernet token) +in ``api_key_encrypted`` — never as plaintext. ``user_id`` is the primary key +(one configured provider per user). +""" + +from datetime import datetime + +from sqlalchemy import DateTime, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base + + +class UserLlmSettingModel(Base): + """ORM model for a per-user LLM provider settings row. + + Attributes: + user_id: Primary key — owner identifier. + provider: Free-form provider label (display only). + base_url: OpenAI-compatible base URL. + api_key_encrypted: Fernet-encrypted API key token (Text). + created_at: Creation timestamp (UTC, non-null). + updated_at: Last update timestamp (UTC, non-null). + """ + + __tablename__ = "user_llm_settings" + + user_id: Mapped[str] = mapped_column(String(255), primary_key=True) + provider: Mapped[str] = mapped_column(String(100), nullable=False) + base_url: Mapped[str] = mapped_column(String(500), nullable=False) + api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/src/infrastructure/database/rls_context.py b/src/infrastructure/database/rls_context.py new file mode 100644 index 0000000..f443da7 --- /dev/null +++ b/src/infrastructure/database/rls_context.py @@ -0,0 +1,59 @@ +"""Context variables for per-request RLS isolation. + +These contextvars are set by ``ComposableAgentsSecurity.verify_credentials`` +after a successful authentication and consumed by the SQLAlchemy +``before_cursor_execute`` event listener on the engine to set PostgreSQL GUCs +(``app.user_id``) so that Row-Level Security policies can filter rows per +authenticated user. + +``current_credential`` holds the raw credential (JWT token or API key) for +audit logging without re-reading the request headers. + +``current_auth_method`` records which authentication method produced the +context (``"jwt"`` or ``"api_key"``). It is consumed by the MCP credential +propagation resolver to decide which outgoing header placeholder +(``${USER_JWT}`` / ``${USER_API_KEY}``) to fill with ``current_credential``. + +``bypass_rls`` is set to ``True`` by the ``system_rls_context`` async context +manager so that background jobs (cron, migrations) can read across all users +without an authenticated principal. +""" + +import contextvars +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +current_user_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_user_id", default=None) +current_credential: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_credential", default=None) +# Authentication method that produced the current context ("jwt" or "api_key"). +# Set by ``ComposableAgentsSecurity.verify_credentials`` alongside +# ``current_user_id`` / ``current_credential``. Consumed by the MCP credential +# propagation resolver (``${USER_JWT}`` / ``${USER_API_KEY}``) to decide which +# credential placeholder to fill. +current_auth_method: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_auth_method", default=None) +# When True, the RLS event listener emits ``SET LOCAL row_security = off`` so +# that system/migration queries can read across all users. +bypass_rls: contextvars.ContextVar[bool] = contextvars.ContextVar("bypass_rls", default=False) + + +@asynccontextmanager +async def system_rls_context() -> AsyncIterator[None]: + """Temporarily disable RLS for the duration of a system / migration block. + + Background jobs and migrations run without an authenticated user and + therefore have no ``current_user_id``. Without this context, RLS policies + would filter out every row (``user_id = NULL`` is always FALSE). + + Usage:: + + async with system_rls_context(): + await run_migrations() + + The ``bypass_rls`` contextvar is reset on exit, including when an exception + propagates out of the ``with`` block. + """ + token = bypass_rls.set(True) + try: + yield + finally: + bypass_rls.reset(token) diff --git a/src/infrastructure/database/rls_listener.py b/src/infrastructure/database/rls_listener.py new file mode 100644 index 0000000..81a0228 --- /dev/null +++ b/src/infrastructure/database/rls_listener.py @@ -0,0 +1,88 @@ +"""SQLAlchemy ``before_cursor_execute`` event listener for PostgreSQL RLS. + +Registers a listener on the engine's sync engine that, before each query, +emits transaction-scoped ``SET LOCAL`` GUCs from the +:mod:`src.infrastructure.database.rls_context` contextvars so that PostgreSQL +Row-Level Security policies can filter rows per authenticated user. + +Behaviour: + +* On **non-postgresql** dialects (SQLite in tests) the listener is a no-op — + ``SET LOCAL`` is a Postgres-only statement and would raise on SQLite. The + listener still runs (so it can be spied on) but does nothing. +* On **postgresql**: + + - If ``bypass_rls`` is ``True`` → emits ``SET LOCAL row_security = off`` so + background jobs / migrations can read across all users. + - Else if ``current_user_id`` is set → emits + ``SELECT set_config('app.user_id', $1, true)`` (transaction-scoped). + - Else (no contextvar, no bypass) → no-op. + +Calling ``cursor.execute`` on the raw DBAPI cursor does **not** re-trigger +``before_cursor_execute`` (only SQLAlchemy's ``conn.execute`` fires it), so +there is no infinite recursion — this is the documented SQLAlchemy recipe +("Switching Databases"). +""" + +import logging + +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine + +from src.domain.logging.messages import LogMessage +from src.infrastructure.database.rls_context import bypass_rls, current_user_id + +logger = logging.getLogger(__name__) + +# Sentinel stored on the sync engine so register_rls_listener is idempotent. +_RLS_LISTENER_FLAG = "_composable_agents_rls_listener_registered" + + +def _set_rls_guc_before_execute(conn, cursor, _statement, _parameters, _context, _executemany) -> None: + """Set PostgreSQL GUCs for RLS from contextvars before each query. + + Args: + conn: SQLAlchemy connection (carries ``dialect``). + cursor: Raw DBAPI cursor — ``cursor.execute`` does NOT re-trigger + this event, so it is safe to emit ``SET LOCAL`` here. + _statement: The SQL statement about to be executed (unused). + _parameters: Bind parameters (unused). + _context: SQLAlchemy execution context (unused). + _executemany: Whether ``executemany`` is used (unused). + """ + dialect_name = conn.dialect.name + + # SQLite (tests) — no-op. SET LOCAL would raise on SQLite. + if dialect_name != "postgresql": + return + + if bypass_rls.get(): + cursor.execute("SET LOCAL row_security = off") + logger.debug(LogMessage.RLS_BYPASS_ENABLED) + return + + uid = current_user_id.get() + if not uid: + return + + # Transaction-scoped (LOCAL) GUC. Using set_config(..., true) is equivalent + # to SET LOCAL but parameterised, avoiding SQL injection. + cursor.execute("SELECT set_config('app.user_id', $1, true)", (uid,)) + logger.debug(LogMessage.RLS_CONTEXT_SET, uid) + + +def register_rls_listener(engine: AsyncEngine) -> None: + """Register the RLS ``before_cursor_execute`` listener on ``engine``. + + Idempotent: calling twice on the same engine does not stack a second + listener (a sentinel flag is set on the sync engine). + + Args: + engine: The async engine whose ``sync_engine`` will receive the + listener. + """ + sync_engine = engine.sync_engine + if getattr(sync_engine, _RLS_LISTENER_FLAG, False): + return + event.listen(sync_engine, "before_cursor_execute", _set_rls_guc_before_execute) + setattr(sync_engine, _RLS_LISTENER_FLAG, True) diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index 37903c0..800b6f4 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -6,6 +6,7 @@ from deepagents import create_deep_agent from deepagents.backends import StoreBackend +from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.store.memory import InMemoryStore @@ -18,11 +19,57 @@ from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader from src.domain.ports.prompt_manager import PromptManager +from src.infrastructure.deepagent.namespace import user_namespaced from src.infrastructure.deepagent.schema_utils import schema_to_pydantic_model logger = logging.getLogger(__name__) +async def _resolve_model( + model_name: str, + llm_credentials_resolver: Callable[[str], Awaitable[tuple[str, str] | None]] | None, +): + """Resolve the ``model`` argument passed to ``create_deep_agent``. + + * When ``llm_credentials_resolver`` is provided AND ``current_user_id`` is + set (authenticated request), resolve the user's ``(base_url, api_key)`` + and build a per-user :class:`ChatOpenAI` instance (passed by reference). + * When the resolver returns ``None`` for a set user, raise + :class:`LlmNotConfiguredError` (422). + * When the resolver is ``None`` OR ``current_user_id`` is unset (no auth + context, tests), fall back to the env-based string model so existing + factory / registry / runner tests stay green. + + Args: + model_name: The configured model name (e.g. ``claude-sonnet-4-5``). + llm_credentials_resolver: Optional async callable resolving the + current user's LLM credentials. + + Returns: + Either a model string (env fallback) or a :class:`ChatOpenAI` instance + (per-user credentials). + + Raises: + LlmNotConfiguredError: When the user is authenticated but has no + configured LLM provider. + """ + from src.domain.errors.llm import LlmNotConfiguredError + from src.domain.errors.messages import ErrorMessage + from src.infrastructure.database.rls_context import current_user_id + + user_id = current_user_id.get() + if llm_credentials_resolver is None or user_id is None: + # Env-based fallback (existing behaviour). + return model_name + + credentials = await llm_credentials_resolver(user_id) + if credentials is None: + raise LlmNotConfiguredError(ErrorMessage.LLM_NOT_CONFIGURED.format(user_id=user_id)) + + base_url, api_key = credentials + return ChatOpenAI(model=model_name, base_url=base_url, api_key=api_key) + + def _to_pg_conn_string(database_url: str) -> str: """Convert an asyncpg-normalized URL to a plain PostgreSQL connection string. @@ -185,13 +232,17 @@ def _resolve_backend(store): from the shared LangGraph store (Postgres). This ensures skills and memories created via the Store File API are visible to all agents. + The namespace is resolved per-request via ``user_namespaced("filesystem")`` + so each authenticated user's skills/memories are isolated. When no user + context is set (tests), the namespace falls back to ``("filesystem",)``. + Args: store: The shared store instance (Postgres or InMemoryStore). Returns: A ``StoreBackend`` instance. """ - return StoreBackend(store=store, namespace=lambda _r: ("filesystem",)) + return StoreBackend(store=store, namespace=lambda _r: user_namespaced("filesystem")) def _resolve_interrupt_on(config: AgentConfig) -> dict | None: @@ -376,33 +427,113 @@ async def _prepare_agent_namespace( Returns: Tuple of (skills_source_path, memory_paths) for create_deep_agent. """ - ns = ("filesystem",) + ns = user_namespaced("filesystem") agent_skills_dir = f"/agents/{agent_name}/skills/" agent_memories_dir = f"/agents/{agent_name}/memories/" - # 1. Cleanup: delete files in agent namespace that are no longer selected - existing_items = await store.asearch(ns, limit=1000) selected_skill_names = {s.rstrip("/").split("/")[-1] for s in skills} selected_memory_files = {m.split("/")[-1] for m in memory} + await _cleanup_stale_agent_files( + store, ns, agent_skills_dir, agent_memories_dir, selected_skill_names, selected_memory_files + ) + await _copy_skills_to_agent_ns(store, ns, agent_skills_dir, skills) + new_memory_paths = await _copy_memories_to_agent_ns(store, ns, agent_memories_dir, memory) + + return agent_skills_dir, new_memory_paths + + +async def _cleanup_stale_agent_files( + store, + ns: tuple[str, ...], + agent_skills_dir: str, + agent_memories_dir: str, + selected_skill_names: set[str], + selected_memory_files: set[str], +) -> None: + """Delete files in agent namespace that are no longer selected. + + Args: + store: The shared LangGraph BaseStore instance. + ns: The resolved user namespace. + agent_skills_dir: Agent skills directory prefix. + agent_memories_dir: Agent memories directory prefix. + selected_skill_names: Currently selected skill names. + selected_memory_files: Currently selected memory filenames. + """ + existing_items = await store.asearch(ns, limit=1000) for item in existing_items: - if item.key.startswith(agent_skills_dir): - remainder = item.key[len(agent_skills_dir) :] - skill_name = remainder.split("/")[0] if "/" in remainder else remainder - if skill_name not in selected_skill_names: - try: - await store.adelete(ns, item.key) - except Exception: - logger.exception("Failed to delete stale agent skill: %s", item.key) - elif item.key.startswith(agent_memories_dir): - filename = item.key[len(agent_memories_dir) :] - if filename not in selected_memory_files: - try: - await store.adelete(ns, item.key) - except Exception: - logger.exception("Failed to delete stale agent memory: %s", item.key) - - # 2. Copy selected skills to agent namespace + await _maybe_delete_stale_item( + store, + ns, + item, + agent_skills_dir, + agent_memories_dir, + selected_skill_names, + selected_memory_files, + ) + + +async def _maybe_delete_stale_item( + store, + ns: tuple[str, ...], + item, + agent_skills_dir: str, + agent_memories_dir: str, + selected_skill_names: set[str], + selected_memory_files: set[str], +) -> None: + """Delete a single store item if it is a stale skill or memory. + + Args: + store: The shared LangGraph BaseStore instance. + ns: The resolved user namespace. + item: A store item to evaluate. + agent_skills_dir: Agent skills directory prefix. + agent_memories_dir: Agent memories directory prefix. + selected_skill_names: Currently selected skill names. + selected_memory_files: Currently selected memory filenames. + """ + if item.key.startswith(agent_skills_dir): + remainder = item.key[len(agent_skills_dir) :] + skill_name = remainder.split("/")[0] if "/" in remainder else remainder + if skill_name not in selected_skill_names: + await _safe_delete(store, ns, item.key, "stale agent skill") + elif item.key.startswith(agent_memories_dir): + filename = item.key[len(agent_memories_dir) :] + if filename not in selected_memory_files: + await _safe_delete(store, ns, item.key, "stale agent memory") + + +async def _safe_delete(store, ns: tuple[str, ...], key: str, label: str) -> None: + """Best-effort delete of a store key, logging failures instead of raising. + + Args: + store: The shared LangGraph BaseStore instance. + ns: The resolved user namespace. + key: The store key to delete. + label: Human-readable label for the log message. + """ + try: + await store.adelete(ns, key) + except Exception: + logger.exception("Failed to delete %s: %s", label, key) + + +async def _copy_skills_to_agent_ns( + store, + ns: tuple[str, ...], + agent_skills_dir: str, + skills: list[str], +) -> None: + """Copy selected skills' ``SKILL.md`` into the agent namespace. + + Args: + store: The shared LangGraph BaseStore instance. + ns: The resolved user namespace. + agent_skills_dir: Agent skills directory prefix. + skills: List of skill directory paths. + """ for skill_dir in skills: skill_name = skill_dir.rstrip("/").split("/")[-1] src_path = f"{skill_dir.rstrip('/')}/SKILL.md" @@ -411,7 +542,24 @@ async def _prepare_agent_namespace( if item is not None: await store.aput(ns, dst_path, item.value) - # 3. Copy selected memories to agent namespace + +async def _copy_memories_to_agent_ns( + store, + ns: tuple[str, ...], + agent_memories_dir: str, + memory: list[str], +) -> list[str]: + """Copy selected memory files into the agent namespace. + + Args: + store: The shared LangGraph BaseStore instance. + ns: The resolved user namespace. + agent_memories_dir: Agent memories directory prefix. + memory: List of memory file paths. + + Returns: + List of destination paths written into the agent namespace. + """ new_memory_paths: list[str] = [] for mem_path in memory: filename = mem_path.split("/")[-1] @@ -420,8 +568,7 @@ async def _prepare_agent_namespace( if item is not None: await store.aput(ns, dst_path, item.value) new_memory_paths.append(dst_path) - - return agent_skills_dir, new_memory_paths + return new_memory_paths async def create_agent_from_config( @@ -429,6 +576,7 @@ async def create_agent_from_config( mcp_tool_loader: McpToolLoader | None = None, prompt_manager: PromptManager | None = None, config_resolver: Callable[[str], Awaitable[AgentConfig]] | None = None, + llm_credentials_resolver: Callable[[str], Awaitable[tuple[str, str] | None]] | None = None, ): """Create a compiled Deep Agent from configuration. @@ -438,6 +586,15 @@ async def create_agent_from_config( prompt_manager: Optional prompt manager for loading system prompts. config_resolver: Optional async callable used to resolve subagent ``agent_ref`` references into full ``AgentConfig`` objects. + llm_credentials_resolver: Optional async callable returning + ``(base_url, api_key_plaintext)`` for the current user. When + provided AND ``current_user_id`` is set, a per-user + :class:`ChatOpenAI` instance is built with these credentials and + passed to ``create_deep_agent`` instead of the string model. When + the resolver returns ``None`` for a set user, + :class:`LlmNotConfiguredError` is raised. When the resolver is + ``None`` OR ``current_user_id`` is unset (no auth context, tests), + the env-based string model fallback is used (current behaviour). Returns: Tuple of (compiled agent graph, response_format_model or None). @@ -455,30 +612,13 @@ async def create_agent_from_config( # per-agent store_backend setting. store = await _get_shared_store() - local_tools = _resolve_tools(config) - mcp_tools: list = [] - if config.mcp_servers and mcp_tool_loader: - logger.info(LogMessage.AGENT_MCP_TOOLS_LOADING, config.name, len(config.mcp_servers)) - mcp_tools = await mcp_tool_loader.load_tools(config.mcp_servers) - logger.info(LogMessage.AGENT_MCP_TOOLS_LOADED, len(mcp_tools), config.name) - all_tools = (local_tools or []) + mcp_tools if (local_tools or mcp_tools) else None - logger.info(LogMessage.AGENT_TOOLS_TOTAL, config.name, len(all_tools) if all_tools else 0) - + all_tools = await _resolve_agent_tools(config, mcp_tool_loader) system_prompt = await get_system_prompt_from_phoenix(config.name, prompt_manager) if prompt_manager else None - - # Prepare agent namespace: copy selected skills and memories to - # /agents/{name}/skills/ and /agents/{name}/memories/ so that - # SkillsMiddleware only loads the selected ones. - skills_source: str | None = None - memory_paths: list[str] | None = None - if config.skills: - skills_source, memory_paths = await _prepare_agent_namespace(store, config.name, config.skills, config.memory) - elif config.memory: - _, memory_paths = await _prepare_agent_namespace(store, config.name, [], config.memory) + skills_source, memory_paths = await _resolve_skills_and_memory(store, config) kwargs = { "name": config.name, - "model": config.model, + "model": await _resolve_model(config.model, llm_credentials_resolver), "system_prompt": system_prompt if system_prompt else config.system_prompt, "tools": all_tools, "checkpointer": checkpointer, @@ -511,6 +651,52 @@ async def create_agent_from_config( return graph, response_format_model +async def _resolve_agent_tools( + config: AgentConfig, + mcp_tool_loader: McpToolLoader | None, +) -> list | None: + """Resolve an agent's combined local + MCP tools list. + + Args: + config: The agent configuration. + mcp_tool_loader: Optional MCP tool loader for remote tools. + + Returns: + Combined tools list or ``None`` when the agent has no tools. + """ + local_tools = _resolve_tools(config) + mcp_tools: list = [] + if config.mcp_servers and mcp_tool_loader: + logger.info(LogMessage.AGENT_MCP_TOOLS_LOADING, config.name, len(config.mcp_servers)) + mcp_tools = await mcp_tool_loader.load_tools(config.mcp_servers) + logger.info(LogMessage.AGENT_MCP_TOOLS_LOADED, len(mcp_tools), config.name) + all_tools = (local_tools or []) + mcp_tools if (local_tools or mcp_tools) else None + logger.info(LogMessage.AGENT_TOOLS_TOTAL, config.name, len(all_tools) if all_tools else 0) + return all_tools + + +async def _resolve_skills_and_memory( + store, + config: AgentConfig, +) -> tuple[str | None, list[str] | None]: + """Prepare the agent namespace and resolve skills/memory paths. + + Args: + store: The shared LangGraph BaseStore instance. + config: The agent configuration. + + Returns: + Tuple of ``(skills_source, memory_paths)``; both are ``None`` when + the agent declares neither skills nor memory. + """ + if config.skills: + return await _prepare_agent_namespace(store, config.name, config.skills, config.memory) + if config.memory: + _, memory_paths = await _prepare_agent_namespace(store, config.name, [], config.memory) + return None, memory_paths + return None, None + + # helper to get system_prompt from Phoenix async def get_system_prompt_from_phoenix(agent_name: str, prompt_manager: PromptManager | None = None) -> str | None: """Get system_prompt from Phoenix for a given agent name.""" diff --git a/src/infrastructure/deepagent/namespace.py b/src/infrastructure/deepagent/namespace.py new file mode 100644 index 0000000..12f86d0 --- /dev/null +++ b/src/infrastructure/deepagent/namespace.py @@ -0,0 +1,39 @@ +"""Namespace resolution helper for per-user LangGraph Store isolation. + +Builds a LangGraph Store namespace tuple prefixed by the current authenticated +user id (read from the ``current_user_id`` contextvar set by +``verify_credentials``). When the contextvar is ``None`` (no auth context, +e.g. existing tests, background jobs), the user prefix is dropped so the +namespace falls back to the legacy global tuple — keeping all pre-existing +tests green. + +Usage:: + + from src.infrastructure.deepagent.namespace import user_namespaced + + ns = user_namespaced("filesystem") # ("u1", "filesystem") or ("filesystem",) +""" + +from src.infrastructure.database.rls_context import current_user_id + + +def user_namespaced(*suffix: str) -> tuple[str, ...]: + """Build a per-user-scoped namespace tuple for the LangGraph Store. + + Args: + *suffix: Namespace suffix segments (e.g. ``"filesystem"``, or + ``"agents", "agent1"``). + + Returns: + ``(user_id, *suffix)`` when ``current_user_id`` is set, otherwise + ``tuple(suffix)`` (legacy global namespace, preserving existing + behaviour for tests and unauthenticated contexts). + + Examples: + >>> user_namespaced("filesystem") # with current_user_id="u1" + ('u1', 'filesystem') + >>> user_namespaced("filesystem") # with current_user_id=None + ('filesystem',) + """ + uid = current_user_id.get() + return (uid, *suffix) if uid else tuple(suffix) diff --git a/src/infrastructure/env_utils.py b/src/infrastructure/env_utils.py index 5198d61..5cce198 100644 --- a/src/infrastructure/env_utils.py +++ b/src/infrastructure/env_utils.py @@ -1,29 +1,157 @@ +"""Environment and user-credential variable resolution utilities. + +``resolve_env_vars`` resolves ``${VAR_NAME}`` patterns using ``os.environ`` +(keeping unresolved placeholders intact). + +``resolve_all_vars`` resolves BOTH ``os.environ`` vars AND the user-credential +placeholders ``${USER_JWT}`` and ``${USER_API_KEY}`` in a single pass. The +user-credential placeholders are resolved from the RLS contextvars: + +* ``${USER_JWT}`` → ``current_credential`` value when + ``current_auth_method == "jwt"``, else empty string. +* ``${USER_API_KEY}`` → ``current_credential`` value when + ``current_auth_method == "api_key"``, else empty string. + +When the contextvars are unset (no auth context, e.g. tests), both +placeholders resolve to empty strings — enabling MCP credential propagation +to forward the current user's credential to remote MCP servers (raganything) +instead of a static env-var key. +""" + import os import re from typing import Any +# Reserved placeholders resolved from the RLS contextvars (NOT os.environ). +_USER_JWT = "USER_JWT" +_USER_API_KEY = "USER_API_KEY" +_USER_PLACEHOLDERS = frozenset({_USER_JWT, _USER_API_KEY}) +_PLACEHOLDER_PATTERN = r"\$\{(\w+)\}" + + +def _resolve_user_placeholder(name: str) -> str: + """Resolve a user-credential placeholder from the RLS contextvars. + + Args: + name: The placeholder name (``USER_JWT`` or ``USER_API_KEY``). + + Returns: + The raw credential when the auth method matches, otherwise an empty + string. When the contextvars are unset, returns an empty string. + """ + # Local import to avoid a circular dependency at module load time + # (rls_context has no dependency on env_utils, but keep it local for clarity). + from src.infrastructure.database.rls_context import current_auth_method, current_credential + + method = current_auth_method.get() + cred = current_credential.get() + if cred is None or method is None: + return "" + if name == _USER_JWT and method == "jwt": + return cred + if name == _USER_API_KEY and method == "api_key": + return cred + return "" + def resolve_env_vars(value: str) -> str: """Resolve ${VAR_NAME} patterns in a string using os.environ. If the variable is not set, the placeholder is kept as-is. + + Note: This does NOT resolve the user-credential placeholders + (``${USER_JWT}`` / ``${USER_API_KEY}``). Use :func:`resolve_all_vars` for + that. """ return re.sub( - r"\$\{(\w+)\}", + _PLACEHOLDER_PATTERN, lambda m: os.environ.get(m.group(1), m.group(0)), value, ) +def resolve_all_vars(value: str) -> str: + """Resolve ${VAR_NAME} patterns using os.environ AND user-credential placeholders. + + Resolves both environment variables (``${OPENROUTER_API_KEY}`` etc.) and + the reserved user-credential placeholders (``${USER_JWT}``, + ``${USER_API_KEY}``) in a single pass. Unresolved os.environ placeholders + are kept as-is; user-credential placeholders always resolve to a string + (empty when the contextvars are unset or the method doesn't match). + + Args: + value: The string potentially containing ``${VAR_NAME}`` placeholders. + + Returns: + The string with all resolvable placeholders substituted. + """ + + def _replace(match: re.Match[str]) -> str: + name = match.group(1) + if name in _USER_PLACEHOLDERS: + return _resolve_user_placeholder(name) + return os.environ.get(name, match.group(0)) + + return re.sub(_PLACEHOLDER_PATTERN, _replace, value) + + def resolve_env_vars_in_dict(mapping: dict[str, Any]) -> dict[str, Any]: """Resolve ${VAR_NAME} patterns in all string values of a dict. - Non-string values are passed through unchanged. + Non-string values are passed through unchanged. Uses + :func:`resolve_all_vars` (os.environ + user-credential placeholders). """ resolved: dict[str, Any] = {} for key, value in mapping.items(): if isinstance(value, str): - resolved[key] = resolve_env_vars(value) + resolved[key] = resolve_all_vars(value) else: resolved[key] = value return resolved + + +def resolve_headers_drop_empty(mapping: dict[str, str]) -> dict[str, str]: + """Resolve placeholders in HTTP headers and drop empty/credential-empty entries. + + Resolves both environment variables and user-credential placeholders + (see :func:`resolve_all_vars`). A header entry is DROPPED when: + + * the resolved value is an empty string (e.g. ``X-API-Key: ""``), OR + * the header contained a user-credential placeholder + (``${USER_JWT}`` / ``${USER_API_KEY}``) that resolved to empty — this + prevents sending a malformed ``Authorization: Bearer `` (with no token) + to a remote MCP server. + + Non-string values are passed through unchanged. User placeholders are + resolved exactly once per header value (the empty-resolution is tracked + during the same pass that builds the resolved string). + + Args: + mapping: The input header mapping (e.g. ``{"Authorization": "Bearer ${USER_JWT}"}``). + + Returns: + A new dict with resolved values; entries dropped per the rules above. + """ + resolved: dict[str, str] = {} + for key, value in mapping.items(): + if not isinstance(value, str): + resolved[key] = value + continue + + user_placeholder_resolved_empty = False + + def _replace(match: re.Match[str]) -> str: + nonlocal user_placeholder_resolved_empty + name = match.group(1) + if name in _USER_PLACEHOLDERS: + rv = _resolve_user_placeholder(name) + if rv == "": + user_placeholder_resolved_empty = True + return rv + return os.environ.get(name, match.group(0)) + + rv = re.sub(r"\$\{(\w+)\}", _replace, value) + if not rv or user_placeholder_resolved_empty: + continue + resolved[key] = rv + return resolved diff --git a/src/infrastructure/mcp/adapter.py b/src/infrastructure/mcp/adapter.py index e7516fd..c241c16 100644 --- a/src/infrastructure/mcp/adapter.py +++ b/src/infrastructure/mcp/adapter.py @@ -10,7 +10,7 @@ from src.domain.errors.messages import ErrorMessage from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader -from src.infrastructure.env_utils import resolve_env_vars +from src.infrastructure.env_utils import resolve_headers_drop_empty logger = logging.getLogger(__name__) @@ -128,9 +128,21 @@ def sync_wrapper(*args, _tc=timed_coro, **kwargs): return patched def _resolve_env_vars(self, mapping: dict[str, str]) -> dict[str, str]: - """Resout les variables d'environnement dans un mapping. + """Resolve ${VAR_NAME} placeholders (os.environ + user credentials) in a mapping. - Les variables au format ${VAR_NAME} sont remplacees par leur valeur - depuis os.environ. Si la variable n'existe pas, le placeholder est conserve. + Delegates to :func:`resolve_headers_drop_empty`, which resolves both + environment variables (``${OPENROUTER_API_KEY}`` etc.) and the + user-credential placeholders (``${USER_JWT}``, ``${USER_API_KEY}``) + from the RLS contextvars, then DROPS entries whose resolved value is + empty or whose user-credential placeholder resolved to empty. This + prevents sending a malformed ``Authorization: Bearer `` (with no token) + or an empty ``X-API-Key`` to a remote MCP server. + + Args: + mapping: The input header/env mapping. + + Returns: + A new dict with resolved values; empty/credential-empty entries + dropped. Non-string values are passed through unchanged. """ - return {key: resolve_env_vars(value) for key, value in mapping.items()} + return resolve_headers_drop_empty(mapping) diff --git a/src/infrastructure/persistent_registry/adapter.py b/src/infrastructure/persistent_registry/adapter.py index a155f82..4cdb4a7 100644 --- a/src/infrastructure/persistent_registry/adapter.py +++ b/src/infrastructure/persistent_registry/adapter.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from src.domain.entities.agent_config import AgentConfig from src.domain.logging.messages import LogMessage @@ -30,6 +31,7 @@ def __init__( prompt_manager: PromptManager | None = None, stream_idle_timeout: float = 120.0, invoke_timeout: float = 120.0, + llm_credentials_resolver: Callable[[str], Awaitable[tuple[str, str] | None]] | None = None, ) -> None: self._config_loader = config_loader self._config_store = config_store @@ -39,6 +41,7 @@ def __init__( self._prompt_manager = prompt_manager self._stream_idle_timeout = stream_idle_timeout self._invoke_timeout = invoke_timeout + self._llm_credentials_resolver = llm_credentials_resolver self._runners: dict[str, AgentRunner] = {} self._lock = asyncio.Lock() @@ -71,7 +74,11 @@ async def config_resolver(name: str) -> AgentConfig: return self._config_loader.load_from_string(referenced_yaml) graph, response_format_model = await create_agent_from_config( - config, self._mcp_tool_loader, self._prompt_manager, config_resolver=config_resolver + config, + self._mcp_tool_loader, + self._prompt_manager, + config_resolver=config_resolver, + llm_credentials_resolver=self._llm_credentials_resolver, ) runner = DeepAgentRunner( graph, diff --git a/src/infrastructure/postgres_api_key/adapter.py b/src/infrastructure/postgres_api_key/adapter.py new file mode 100644 index 0000000..8fa8cc1 --- /dev/null +++ b/src/infrastructure/postgres_api_key/adapter.py @@ -0,0 +1,197 @@ +"""PostgreSQL adapter for the :class:`ApiKeyRepository` port. + +Each method opens its own :class:`AsyncSession` (session-per-method) so +concurrent requests do not share a session. ``SQLAlchemyError`` is translated +to :class:`StorageError` everywhere, and ``ApiKeyNotFoundError`` is raised by +``revoke`` when no row matches ``(user_id, key_id)``. +""" + +import logging +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from src.domain.entities.auth.api_key import ApiKeyView +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.security import ApiKeyNotFoundError +from src.domain.errors.storage import StorageError +from src.domain.logging.messages import LogMessage +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.infrastructure.database.models.api_key import ApiKeyModel + +logger = logging.getLogger(__name__) + + +def _model_to_view(model: ApiKeyModel) -> ApiKeyView: + """Project an :class:`ApiKeyModel` row into a safe :class:`ApiKeyView`. + + The hash is deliberately excluded so it can never leak through the list + endpoint. + + Args: + model: The ORM row to project. + + Returns: + A :class:`ApiKeyView` with no hash field. + """ + return ApiKeyView( + id=model.id, + name=model.name, + key_prefix=model.key_prefix, + created_at=model.created_at, + last_used_at=model.last_used_at, + revoked_at=model.revoked_at, + ) + + +class PostgresApiKeyRepository(ApiKeyRepository): + """Adapter that persists per-user API keys in PostgreSQL via SQLAlchemy async. + + Each method creates its own AsyncSession from the engine, ensuring + thread-safety and proper session lifecycle for concurrent operations. + """ + + def __init__(self, engine: AsyncEngine) -> None: + self._engine = engine + + async def find_active_by_hash(self, key_hash: str) -> tuple[str, str] | None: + """Return ``(user_id, key_id)`` for the active key matching ``key_hash``. + + Args: + key_hash: The SHA-256 hex digest of the API key plaintext. + + Returns: + A ``(user_id, key_id)`` tuple if an active (non-revoked) key matches, + else ``None``. + + Raises: + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + stmt = select(ApiKeyModel.user_id, ApiKeyModel.id).where( + ApiKeyModel.key_hash == key_hash, + ApiKeyModel.revoked_at.is_(None), + ) + row = (await session.execute(stmt)).first() + return (row.user_id, row.id) if row is not None else None + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def create(self, user_id: str, name: str, key_hash: str, key_prefix: str) -> str: + """Persist a new API key and return its generated id. + + Args: + user_id: Owner of the key. + name: Human-readable label. + key_hash: SHA-256 hex digest of the plaintext (never the plaintext). + key_prefix: First 10 chars of the plaintext. + + Returns: + The generated key id (uuid hex). + + Raises: + StorageError: If the database operation fails. + """ + key_id = uuid4().hex + now = datetime.now(UTC) + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + session.add( + ApiKeyModel( + id=key_id, + user_id=user_id, + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + revoked_at=None, + last_used_at=None, + created_at=now, + ) + ) + await session.commit() + logger.info(LogMessage.API_KEY_CREATED, key_id, user_id) + return key_id + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def list_by_user(self, user_id: str) -> list[ApiKeyView]: + """Return all API keys owned by ``user_id`` ordered by ``created_at`` desc. + + Revoked keys are included. The hash is never part of the returned views. + + Args: + user_id: Owner whose keys are returned. + + Returns: + A list of :class:`ApiKeyView` (possibly empty). + + Raises: + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + stmt = select(ApiKeyModel).where(ApiKeyModel.user_id == user_id).order_by(ApiKeyModel.created_at.desc()) + models = (await session.execute(stmt)).scalars().all() + views = [_model_to_view(m) for m in models] + logger.info(LogMessage.API_KEY_LISTED, len(views), user_id) + return views + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def revoke(self, user_id: str, key_id: str) -> None: + """Revoke the key ``key_id`` owned by ``user_id``. + + Idempotent: revoking an already-revoked key is a no-op success. + + Args: + user_id: Owner of the key (a key owned by another user is treated + as not found). + key_id: Id of the key to revoke. + + Raises: + ApiKeyNotFoundError: If no key matches ``(user_id, key_id)``. + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + stmt = select(ApiKeyModel).where( + ApiKeyModel.id == key_id, + ApiKeyModel.user_id == user_id, + ) + model = (await session.execute(stmt)).scalar_one_or_none() + if model is None: + raise ApiKeyNotFoundError(ErrorMessage.API_KEY_NOT_FOUND.format(key_id=key_id)) + if model.revoked_at is not None: + # Idempotent: already revoked — no-op success. + return + model.revoked_at = datetime.now(UTC) + await session.commit() + logger.info(LogMessage.API_KEY_REVOKED, key_id, user_id) + except ApiKeyNotFoundError: + raise + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def touch_last_used(self, key_id: str) -> None: + """Update ``last_used_at`` to now for ``key_id``. + + Silent no-op if the key does not exist (used on the auth hot path where + a stale/revoked key may still be presented). + + Args: + key_id: Id of the key that was just used. + + Raises: + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + stmt = update(ApiKeyModel).where(ApiKeyModel.id == key_id).values(last_used_at=datetime.now(UTC)) + await session.execute(stmt) + await session.commit() + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e diff --git a/src/infrastructure/postgres_repository/adapter.py b/src/infrastructure/postgres_repository/adapter.py index 7ff1c7e..46ff7ef 100644 --- a/src/infrastructure/postgres_repository/adapter.py +++ b/src/infrastructure/postgres_repository/adapter.py @@ -1,3 +1,11 @@ +"""PostgreSQL adapter for the :class:`AgentConfigRepository` port. + +Per-user isolation (RLS plumbing): the repository reads the +``current_user_id`` contextvar and filters / sets ``user_id`` accordingly. +When the contextvar is ``None`` (no auth context) no filter is applied so +existing behaviour is preserved. +""" + import logging from sqlalchemy import select @@ -11,6 +19,7 @@ from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_repository import AgentConfigRepository from src.infrastructure.database.models.agent_config import AgentConfigModel +from src.infrastructure.database.rls_context import current_user_id logger = logging.getLogger(__name__) @@ -23,6 +32,7 @@ def _model_to_metadata(model: AgentConfigModel) -> AgentConfigMetadata: description=model.description, created_at=model.created_at, updated_at=model.updated_at, + user_id=model.user_id, ) @@ -36,10 +46,17 @@ class PostgresAgentConfigRepository(AgentConfigRepository): def __init__(self, engine: AsyncEngine) -> None: self._engine = engine + @staticmethod + def _current_user_id() -> str | None: + """Return the ``current_user_id`` contextvar value or ``None``.""" + return current_user_id.get() + async def save(self, metadata: AgentConfigMetadata) -> None: """Insert or update agent configuration metadata. Uses merge for upsert semantics: insert if new, update if exists. + The row's ``user_id`` is set from the ``current_user_id`` contextvar + (or ``""`` when unset). Args: metadata: The agent configuration metadata to persist. @@ -49,6 +66,7 @@ async def save(self, metadata: AgentConfigMetadata) -> None: """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: + uid = self._current_user_id() or "" model = AgentConfigModel( name=metadata.name, model=metadata.model, @@ -56,6 +74,7 @@ async def save(self, metadata: AgentConfigMetadata) -> None: description=metadata.description, created_at=metadata.created_at, updated_at=metadata.updated_at, + user_id=uid, ) await session.merge(model) await session.commit() @@ -68,6 +87,9 @@ async def save(self, metadata: AgentConfigMetadata) -> None: async def get(self, name: str) -> AgentConfigMetadata: """Retrieve metadata by agent name. + When ``current_user_id`` is set, a ``WHERE user_id == `` filter + is applied so a config owned by another user appears as not found. + Args: name: The agent name to look up. @@ -75,12 +97,20 @@ async def get(self, name: str) -> AgentConfigMetadata: The agent configuration metadata. Raises: - AgentNotFoundError: If no row exists for this name. + AgentNotFoundError: If no row exists for this name (or it is + owned by another user when the contextvar is set). StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - model = await session.get(AgentConfigModel, name) + uid = self._current_user_id() + if uid is None: + model = await session.get(AgentConfigModel, name) + else: + stmt = select(AgentConfigModel).where( + AgentConfigModel.name == name, AgentConfigModel.user_id == uid + ) + model = (await session.execute(stmt)).scalar_one_or_none() if model is None: raise AgentNotFoundError(ErrorMessage.AGENT_CONFIG_NOT_FOUND.format(name=name)) return _model_to_metadata(model) @@ -90,17 +120,25 @@ async def get(self, name: str) -> AgentConfigMetadata: raise StorageError(ErrorMessage.STORAGE_FAILED_GET_AGENT_CONFIG.format(name=name, error=e)) from e async def list_all(self) -> list[AgentConfigMetadata]: - """List all agent configuration metadata. + """List all agent configuration metadata visible to the current user. + + When ``current_user_id`` is set, only configs with + ``user_id == `` are returned. When the contextvar is ``None`` no + filter is applied (existing behaviour). Returns: - A list of all stored metadata entries. + A list of all visible metadata entries. Raises: StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - result = await session.execute(select(AgentConfigModel).order_by(AgentConfigModel.name)) + uid = self._current_user_id() + stmt = select(AgentConfigModel).order_by(AgentConfigModel.name) + if uid is not None: + stmt = stmt.where(AgentConfigModel.user_id == uid) + result = await session.execute(stmt) models = result.scalars().all() return [_model_to_metadata(m) for m in models] except SQLAlchemyError as e: @@ -109,16 +147,27 @@ async def list_all(self) -> list[AgentConfigMetadata]: async def delete(self, name: str) -> None: """Delete metadata by agent name. + When ``current_user_id`` is set, only a config owned by the current + user can be deleted (otherwise :class:`AgentNotFoundError` is raised). + Args: name: The agent name to delete. Raises: - AgentNotFoundError: If no row was deleted. + AgentNotFoundError: If no row was deleted (or the config is owned + by another user when the contextvar is set). StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - model = await session.get(AgentConfigModel, name) + uid = self._current_user_id() + if uid is None: + model = await session.get(AgentConfigModel, name) + else: + stmt = select(AgentConfigModel).where( + AgentConfigModel.name == name, AgentConfigModel.user_id == uid + ) + model = (await session.execute(stmt)).scalar_one_or_none() if model is None: raise AgentNotFoundError(ErrorMessage.AGENT_CONFIG_NOT_FOUND.format(name=name)) await session.delete(model) @@ -132,18 +181,29 @@ async def delete(self, name: str) -> None: async def exists(self, name: str) -> bool: """Check whether metadata exists for the given agent name. + When ``current_user_id`` is set, only configs owned by the current + user are considered. + Args: name: The agent name to check. Returns: - True if metadata exists, False otherwise. + True if metadata exists (and is owned by the current user when the + contextvar is set), False otherwise. Raises: StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - model = await session.get(AgentConfigModel, name) + uid = self._current_user_id() + if uid is None: + model = await session.get(AgentConfigModel, name) + return model is not None + stmt = select(AgentConfigModel.name).where( + AgentConfigModel.name == name, AgentConfigModel.user_id == uid + ) + model = (await session.execute(stmt)).scalar_one_or_none() return model is not None except SQLAlchemyError as e: raise StorageError(ErrorMessage.STORAGE_FAILED_EXISTS_AGENT_CONFIG.format(name=name, error=e)) from e diff --git a/src/infrastructure/postgres_thread/adapter.py b/src/infrastructure/postgres_thread/adapter.py index ea6e8a1..006eb5b 100644 --- a/src/infrastructure/postgres_thread/adapter.py +++ b/src/infrastructure/postgres_thread/adapter.py @@ -3,6 +3,17 @@ Each method opens its own :class:`AsyncSession` (session-per-method). The ``add_message`` method has been removed — message persistence now goes through :class:`~src.infrastructure.postgres_trace.adapter.PostgresTraceEventRepository`. + +Per-user isolation (RLS plumbing): + +* The repository reads the ``current_user_id`` contextvar via + :meth:`_current_user_id`. +* On **writes** (``create``) the row's ``user_id`` is set to the contextvar + value (or ``""`` when unset, preserving existing behaviour). +* On **reads** (``get`` / ``list_all``) and **delete** a ``WHERE user_id == + `` filter is added when the contextvar is set. When the + contextvar is ``None`` (no auth context, pre-auth-core tests) no filter is + applied so existing behaviour is preserved. """ import logging @@ -21,6 +32,7 @@ from src.domain.errors.thread import ThreadNotFoundError from src.domain.ports.thread_repository import ThreadRepository from src.infrastructure.database.models.thread import ThreadModel +from src.infrastructure.database.rls_context import current_user_id logger = logging.getLogger(__name__) @@ -51,6 +63,7 @@ def _model_to_thread(thread_model: ThreadModel) -> Thread: metadata=m.event_metadata, timestamp=m.timestamp, sequence=m.sequence, + user_id=m.user_id, ) for m in events_sorted ] @@ -60,6 +73,7 @@ def _model_to_thread(thread_model: ThreadModel) -> Thread: trace_events=trace_events, created_at=thread_model.created_at, updated_at=thread_model.updated_at, + user_id=thread_model.user_id, ) @@ -73,9 +87,18 @@ class PostgresThreadRepository(ThreadRepository): def __init__(self, engine: AsyncEngine) -> None: self._engine = engine + @staticmethod + def _current_user_id() -> str | None: + """Return the ``current_user_id`` contextvar value or ``None``.""" + return current_user_id.get() + async def create(self, agent_name: str) -> Thread: """Create a new conversation thread. + The row's ``user_id`` is set from the ``current_user_id`` contextvar + (or ``""`` when the contextvar is unset, preserving existing + behaviour). + Args: agent_name: Name of the agent owning this thread. @@ -88,11 +111,13 @@ async def create(self, agent_name: str) -> Thread: async with AsyncSession(self._engine, expire_on_commit=False) as session: try: now = datetime.now(UTC) + uid = self._current_user_id() or "" model = ThreadModel( id=str(uuid4()), agent_name=agent_name, created_at=now, updated_at=now, + user_id=uid, ) session.add(model) await session.commit() @@ -103,6 +128,7 @@ async def create(self, agent_name: str) -> Thread: trace_events=[], created_at=model.created_at, updated_at=model.updated_at, + user_id=model.user_id, ) except SQLAlchemyError as e: raise StorageError(ErrorMessage.THREAD_FAILED_CREATE.format(error=e)) from e @@ -110,6 +136,9 @@ async def create(self, agent_name: str) -> Thread: async def get(self, thread_id: str) -> Thread: """Retrieve a thread by its ID. + When ``current_user_id`` is set, a ``WHERE user_id == `` filter + is applied so a thread owned by another user appears as not found. + Args: thread_id: The unique thread identifier. @@ -117,12 +146,22 @@ async def get(self, thread_id: str) -> Thread: The domain Thread with all trace events. Raises: - ThreadNotFoundError: If no thread exists with this ID. + ThreadNotFoundError: If no thread exists with this ID (or it is + owned by another user when the contextvar is set). StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - model = await session.get(ThreadModel, thread_id, options=[selectinload(ThreadModel.trace_events)]) + uid = self._current_user_id() + if uid is None: + model = await session.get(ThreadModel, thread_id, options=[selectinload(ThreadModel.trace_events)]) + else: + stmt = ( + select(ThreadModel) + .options(selectinload(ThreadModel.trace_events)) + .where(ThreadModel.id == thread_id, ThreadModel.user_id == uid) + ) + model = (await session.execute(stmt)).scalar_one_or_none() if model is None: raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) return _model_to_thread(model) @@ -132,21 +171,29 @@ async def get(self, thread_id: str) -> Thread: raise StorageError(ErrorMessage.THREAD_FAILED_GET.format(thread_id=thread_id, error=e)) from e async def list_all(self) -> list[Thread]: - """List all conversation threads. + """List all conversation threads visible to the current user. + + When ``current_user_id`` is set, only threads with + ``user_id == `` are returned. When the contextvar is ``None`` no + filter is applied (existing behaviour). Returns: - A list of all Thread entities. + A list of all visible Thread entities. Raises: StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - result = await session.execute( + uid = self._current_user_id() + stmt = ( select(ThreadModel) .options(selectinload(ThreadModel.trace_events)) .order_by(ThreadModel.created_at.desc()) ) + if uid is not None: + stmt = stmt.where(ThreadModel.user_id == uid) + result = await session.execute(stmt) models = result.scalars().all() return [_model_to_thread(model) for model in models] except SQLAlchemyError as e: @@ -155,16 +202,25 @@ async def list_all(self) -> list[Thread]: async def delete(self, thread_id: str) -> None: """Delete a thread and all its trace events. + When ``current_user_id`` is set, only a thread owned by the current + user can be deleted (otherwise :class:`ThreadNotFoundError` is raised). + Args: thread_id: The unique thread identifier. Raises: - ThreadNotFoundError: If no thread exists with this ID. + ThreadNotFoundError: If no thread exists with this ID (or it is + owned by another user when the contextvar is set). StorageError: If the database operation fails. """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - model = await session.get(ThreadModel, thread_id) + uid = self._current_user_id() + if uid is None: + model = await session.get(ThreadModel, thread_id) + else: + stmt = select(ThreadModel).where(ThreadModel.id == thread_id, ThreadModel.user_id == uid) + model = (await session.execute(stmt)).scalar_one_or_none() if model is None: raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) await session.delete(model) diff --git a/src/infrastructure/postgres_trace/adapter.py b/src/infrastructure/postgres_trace/adapter.py index 9bd6333..4c40128 100644 --- a/src/infrastructure/postgres_trace/adapter.py +++ b/src/infrastructure/postgres_trace/adapter.py @@ -2,6 +2,13 @@ Each method opens its own :class:`AsyncSession` (session-per-method) to ensure thread-safety and proper session lifecycle under concurrent FastAPI requests. + +Per-user isolation (RLS plumbing): the repository reads the +``current_user_id`` contextvar and filters the parent thread lookup by +``user_id`` so a user can only add/list events on threads they own. The +``user_id`` is denormalized onto each ``trace_events`` row on insert so list +queries can filter without a JOIN. When the contextvar is ``None`` (no auth +context) no filter is applied so existing behaviour is preserved. """ import logging @@ -17,6 +24,7 @@ from src.domain.ports.trace_event_repository import TraceEventRepository from src.infrastructure.database.models.thread import ThreadModel from src.infrastructure.database.models.trace_event import TraceEventModel +from src.infrastructure.database.rls_context import current_user_id logger = logging.getLogger(__name__) @@ -34,6 +42,7 @@ def _model_to_event(model: TraceEventModel) -> TraceEvent: metadata=model.event_metadata, timestamp=model.timestamp, sequence=model.sequence, + user_id=model.user_id, ) @@ -43,11 +52,27 @@ class PostgresTraceEventRepository(TraceEventRepository): def __init__(self, engine: AsyncEngine) -> None: self._engine = engine - async def _assert_thread_exists(self, session: AsyncSession, thread_id: str) -> None: - """Raise ThreadNotFoundError if the thread is not present.""" - thread = await session.get(ThreadModel, thread_id) + @staticmethod + def _current_user_id() -> str | None: + """Return the ``current_user_id`` contextvar value or ``None``.""" + return current_user_id.get() + + async def _assert_thread_exists(self, session: AsyncSession, thread_id: str) -> ThreadModel: + """Return the parent thread row, filtered by ``user_id`` when set. + + Raises: + ThreadNotFoundError: If the thread does not exist (or is owned by + another user when the contextvar is set). + """ + uid = self._current_user_id() + if uid is None: + thread = await session.get(ThreadModel, thread_id) + else: + stmt = select(ThreadModel).where(ThreadModel.id == thread_id, ThreadModel.user_id == uid) + thread = (await session.execute(stmt)).scalar_one_or_none() if thread is None: raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) + return thread async def add(self, thread_id: str, event: TraceEvent) -> None: """Persist a single trace event. @@ -62,8 +87,8 @@ async def add(self, thread_id: str, event: TraceEvent) -> None: """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - await self._assert_thread_exists(session, thread_id) - session.add(self._to_model(event)) + thread = await self._assert_thread_exists(session, thread_id) + session.add(self._to_model(event, user_id=thread.user_id)) await session.commit() except ThreadNotFoundError: raise @@ -85,8 +110,8 @@ async def add_batch(self, thread_id: str, events: list[TraceEvent]) -> None: """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - await self._assert_thread_exists(session, thread_id) - session.add_all([self._to_model(e) for e in events]) + thread = await self._assert_thread_exists(session, thread_id) + session.add_all([self._to_model(e, user_id=thread.user_id) for e in events]) await session.commit() except ThreadNotFoundError: raise @@ -173,8 +198,13 @@ async def list_messages(self, thread_id: str) -> list[TraceEvent]: raise StorageError(ErrorMessage.TRACE_FAILED_LIST.format(thread_id=thread_id, error=e)) from e @staticmethod - def _to_model(event: TraceEvent) -> TraceEventModel: - """Convert a domain TraceEvent to its ORM model.""" + def _to_model(event: TraceEvent, *, user_id: str = "") -> TraceEventModel: + """Convert a domain TraceEvent to its ORM model. + + Args: + event: The domain event to convert. + user_id: Owner to denormalize onto the row (defaults to ``""``). + """ return TraceEventModel( id=event.id, thread_id=event.thread_id, @@ -186,4 +216,5 @@ def _to_model(event: TraceEvent) -> TraceEventModel: event_metadata=event.metadata, timestamp=event.timestamp, sequence=event.sequence, + user_id=user_id, ) diff --git a/src/infrastructure/postgres_user_llm/adapter.py b/src/infrastructure/postgres_user_llm/adapter.py new file mode 100644 index 0000000..5a9a834 --- /dev/null +++ b/src/infrastructure/postgres_user_llm/adapter.py @@ -0,0 +1,198 @@ +"""PostgreSQL adapter for the :class:`UserLlmSettingsRepository` port. + +Each method opens its own :class:`AsyncSession` (session-per-method). The +``get`` method decrypts the API key on demand (via the injected +:class:`FernetCrypto`) and returns a masked preview (``api_key_masked``) — +never the full plaintext. ``get_decrypted`` returns the plaintext for the +agent factory (per-request decryption; Fernet is fast). + +The repository is RLS-aware: the SQLAlchemy ``before_cursor_execute`` listener +emits ``SET LOCAL app.user_id`` so PostgreSQL Row-Level Security policies +filter rows per user. On SQLite (tests) the contextvar is ignored and +``user_id`` is the primary key, so isolation is inherent. +""" + +import logging +from datetime import UTC, datetime + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from src.domain.entities.user_llm_settings import UserLlmSettings +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.storage import StorageError +from src.domain.logging.messages import LogMessage +from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository +from src.infrastructure.crypto.fernet_crypto import FernetCrypto +from src.infrastructure.database.models.user_llm_setting import UserLlmSettingModel + +logger = logging.getLogger(__name__) + + +def _mask(api_key_plaintext: str) -> str: + """Return a masked preview of an API key (first 3 + ``...`` + last 3). + + For very short keys (<=6 chars) the whole key is masked as ``***``. + + Args: + api_key_plaintext: The decrypted API key. + + Returns: + A masked string safe to expose in GET responses. + """ + if len(api_key_plaintext) <= 6: + return "***" + return f"{api_key_plaintext[:3]}...{api_key_plaintext[-3:]}" + + +class PostgresUserLlmSettingsRepository(UserLlmSettingsRepository): + """Adapter that persists per-user LLM settings in PostgreSQL via SQLAlchemy async. + + The :class:`FernetCrypto` dependency is injected so the adapter stays + decoupled from application settings (DIP). Each method creates its own + AsyncSession from the engine, ensuring thread-safety and proper session + lifecycle for concurrent operations. + """ + + def __init__(self, engine: AsyncEngine, crypto: FernetCrypto) -> None: + self._engine = engine + self._crypto = crypto + + async def get(self, user_id: str) -> UserLlmSettings | None: + """Return the user's settings with a masked API key, or ``None``. + + Args: + user_id: Owner identifier. + + Returns: + A :class:`UserLlmSettings` with ``api_key_masked`` (decrypted then + masked on demand — never the full key), or ``None`` if absent. + + Raises: + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + model = await session.get(UserLlmSettingModel, user_id) + if model is None: + return None + # Decrypt to mask — the masked preview is derived from the + # plaintext (more useful than masking the encrypted token). If + # decryption fails (corrupted / wrong key), fall back to None + # rather than crashing the GET. + masked: str | None + try: + plaintext = self._crypto.decrypt(model.api_key_encrypted) + masked = _mask(plaintext) + except Exception: + logger.warning(LogMessage.LLM_SETTINGS_DECRYPT_FAILED, user_id) + masked = None + return UserLlmSettings( + user_id=model.user_id, + provider=model.provider, + base_url=model.base_url, + api_key_masked=masked, + created_at=model.created_at, + updated_at=model.updated_at, + ) + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def upsert( + self, + user_id: str, + provider: str, + base_url: str, + api_key_encrypted: str, + ) -> UserLlmSettings: + """Insert or update the user's settings (encrypted API key). + + Args: + user_id: Owner identifier. + provider: Free-form provider label. + base_url: OpenAI-compatible base URL. + api_key_encrypted: Fernet-encrypted API key token. + + Returns: + The upserted :class:`UserLlmSettings` (masked key). + + Raises: + StorageError: If the database operation fails. + """ + now = datetime.now(UTC) + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + model = await session.get(UserLlmSettingModel, user_id) + if model is None: + model = UserLlmSettingModel( + user_id=user_id, + provider=provider, + base_url=base_url, + api_key_encrypted=api_key_encrypted, + created_at=now, + updated_at=now, + ) + session.add(model) + else: + model.provider = provider + model.base_url = base_url + model.api_key_encrypted = api_key_encrypted + model.updated_at = now + await session.commit() + # Build masked preview by decrypting the just-stored token. + masked: str | None + try: + masked = _mask(self._crypto.decrypt(api_key_encrypted)) + except Exception: + masked = None + return UserLlmSettings( + user_id=model.user_id, + provider=model.provider, + base_url=model.base_url, + api_key_masked=masked, + created_at=model.created_at, + updated_at=model.updated_at, + ) + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def delete(self, user_id: str) -> None: + """Delete the user's settings. No-op when absent. + + Args: + user_id: Owner identifier. + + Raises: + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + model = await session.get(UserLlmSettingModel, user_id) + if model is None: + return + await session.delete(model) + await session.commit() + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e + + async def get_decrypted(self, user_id: str) -> tuple[str, str] | None: + """Return ``(base_url, api_key_plaintext)`` for the agent factory. + + Args: + user_id: Owner identifier. + + Returns: + A ``(base_url, api_key_plaintext)`` tuple, or ``None`` if absent. + + Raises: + StorageError: If the database operation fails. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + model = await session.get(UserLlmSettingModel, user_id) + if model is None: + return None + plaintext = self._crypto.decrypt(model.api_key_encrypted) + return model.base_url, plaintext + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.STORAGE_FAILED_API_KEY_OP.format(error=e)) from e diff --git a/src/infrastructure/store_file/adapter.py b/src/infrastructure/store_file/adapter.py index 3ccd20b..6a4bc47 100644 --- a/src/infrastructure/store_file/adapter.py +++ b/src/infrastructure/store_file/adapter.py @@ -1,5 +1,7 @@ """LangGraph ``BaseStore`` adapter implementing :class:`StoreFileRepository`.""" +from collections.abc import Callable + from langgraph.store.base import BaseStore from src.domain.ports.store_file_repository import StoreFilePreview, StoreFileRepository @@ -12,17 +14,61 @@ class LangGraphStoreFileRepository(StoreFileRepository): Files are stored as key-value pairs where the key is the file path and the value is ``{"content": str, "encoding": "utf-8"}``. + + The namespace can be supplied either as a static tuple (``namespace``) or + as a callable (``namespace_provider``) evaluated on each method call. + The callable form enables per-user isolation: wire it to + ``lambda: user_namespaced("filesystem")`` so the namespace becomes + ``(user_id, "filesystem")`` when ``current_user_id`` is set, and falls + back to ``("filesystem",)`` when it is ``None`` (legacy / tests). + + When neither argument is provided, the default static namespace + ``("filesystem",)`` is used (backward compatibility with existing tests + that construct ``LangGraphStoreFileRepository(store=...)``). """ - def __init__(self, store: BaseStore, namespace: tuple[str, ...] = _DEFAULT_NAMESPACE) -> None: + def __init__( + self, + store: BaseStore, + namespace: tuple[str, ...] | None = None, + namespace_provider: Callable[[], tuple[str, ...]] | None = None, + ) -> None: """Initialize the repository. Args: store: The LangGraph ``BaseStore`` instance (InMemoryStore or AsyncPostgresStore). - namespace: Namespace tuple for scoping files (default ``("filesystem",)``). + namespace: Static namespace tuple for scoping files. Ignored when + ``namespace_provider`` is provided. Defaults to + ``("filesystem",)`` when both ``namespace`` and + ``namespace_provider`` are ``None``. + namespace_provider: Optional callable returning the current + namespace tuple, evaluated on each method call. Enables + per-user isolation (e.g. ``lambda: user_namespaced("filesystem")``). + + Raises: + ValueError: If both ``namespace`` and ``namespace_provider`` are + provided (ambiguous configuration). """ + if namespace is not None and namespace_provider is not None: + raise ValueError("Provide either 'namespace' or 'namespace_provider', not both") self._store = store - self._namespace = namespace + self._namespace_provider = namespace_provider + # Only used when no provider is given (static default / explicit override). + self._static_namespace = namespace if namespace is not None else _DEFAULT_NAMESPACE + + def _resolve_namespace(self) -> tuple[str, ...]: + """Resolve the namespace for the current call. + + When a ``namespace_provider`` is configured, it is evaluated on each + call so the namespace reflects the current ``current_user_id`` + contextvar. Otherwise the static namespace is returned. + + Returns: + The namespace tuple to scope store operations. + """ + if self._namespace_provider is not None: + return self._namespace_provider() + return self._static_namespace async def list_files(self, prefix: str) -> list[str]: """List file paths in the store that start with the given prefix. @@ -33,7 +79,8 @@ async def list_files(self, prefix: str) -> list[str]: Returns: A list of file path strings matching the prefix. """ - items = await self._store.asearch(self._namespace, limit=1000) + ns = self._resolve_namespace() + items = await self._store.asearch(ns, limit=1000) return [item.key for item in items if item.key.startswith(prefix)] async def list_files_with_preview(self, prefix: str, preview_chars: int) -> list[StoreFilePreview]: @@ -48,7 +95,8 @@ async def list_files_with_preview(self, prefix: str, preview_chars: int) -> list Returns: A list of ``StoreFilePreview`` objects. """ - items = await self._store.asearch(self._namespace, limit=1000) + ns = self._resolve_namespace() + items = await self._store.asearch(ns, limit=1000) return [ StoreFilePreview( path=item.key, @@ -68,7 +116,8 @@ async def get_file(self, path: str) -> str | None: The file content as a string, or ``None`` if not found or the stored value is malformed (missing ``content`` key). """ - item = await self._store.aget(self._namespace, path) + ns = self._resolve_namespace() + item = await self._store.aget(ns, path) if item is None: return None return item.value.get("content") @@ -80,7 +129,8 @@ async def put_file(self, path: str, content: str) -> None: path: The file path to write. content: The UTF-8 text content to store. """ - await self._store.aput(self._namespace, path, {"content": content, "encoding": "utf-8"}) + ns = self._resolve_namespace() + await self._store.aput(ns, path, {"content": content, "encoding": "utf-8"}) async def delete_file(self, path: str) -> None: """Delete a file from the store. @@ -90,4 +140,5 @@ async def delete_file(self, path: str) -> None: Args: path: The file path to delete. """ - await self._store.adelete(self._namespace, path) + ns = self._resolve_namespace() + await self._store.adelete(ns, path) diff --git a/src/main.py b/src/main.py index 2e3ad87..fc501ec 100644 --- a/src/main.py +++ b/src/main.py @@ -10,12 +10,14 @@ from fastapi.responses import JSONResponse from src.application.routes.agents import router as agents_router +from src.application.routes.api_keys import router as api_keys_router from src.application.routes.chat import router as chat_router from src.application.routes.health import router as health_router from src.application.routes.prompt import router as prompt_router from src.application.routes.store import router as store_router from src.application.routes.threads import router as threads_router from src.application.routes.trace import router as trace_router +from src.application.routes.user_llm_settings import router as user_llm_settings_router from src.application.routes.websocket import router as websocket_router from src.config import Settings from src.dependencies import ( @@ -29,13 +31,14 @@ from src.domain.errors.base import DomainError from src.domain.errors.config import ConfigError, ConfigNotFoundError, ConfigValidationError from src.domain.errors.hitl import InvalidHitlActionError +from src.domain.errors.llm import LlmNotConfiguredError from src.domain.errors.mcp import McpError from src.domain.errors.prompt import ( PromptAlreadyExistsError, PromptManagerUnavailableError, PromptNotFoundError, ) -from src.domain.errors.security import InvalidApiKeyError +from src.domain.errors.security import AuthenticationError, InvalidApiKeyError from src.domain.errors.storage import StorageError from src.domain.errors.store_file import StoreFileNotFoundError from src.domain.errors.thread import ThreadNotFoundError @@ -112,14 +115,19 @@ async def lifespan(_app: FastAPI): # because FastAPI APIRouter(dependencies=...) does not apply to WebSocket endpoints. app.include_router(websocket_router) -# All non-WebSocket routes except health are protected behind the API key check. -protected = APIRouter(dependencies=[Depends(security.verify_api_key)]) +# All non-WebSocket routes except health are protected behind dual auth +# (JWT bearer token OR per-user API key) via verify_credentials. The +# master-key verify_api_key stays available for backward-compat and is still +# used by the WebSocket router's fallback path. +protected = APIRouter(dependencies=[Depends(security.verify_credentials)]) protected.include_router(threads_router) protected.include_router(chat_router) protected.include_router(trace_router) protected.include_router(agents_router) protected.include_router(prompt_router) protected.include_router(store_router) +protected.include_router(api_keys_router) +protected.include_router(user_llm_settings_router) app.include_router(protected) @@ -216,6 +224,18 @@ async def invalid_api_key_handler(_request: Request, exc: InvalidApiKeyError) -> return _error_response(exc) +def authentication_error_handler(_request: Request, exc: AuthenticationError) -> JSONResponse: + """Map :class:`AuthenticationError` (raised by verify_credentials) to 401.""" + logger.warning("Authentication failed: %s", exc.detail) + return _error_response(exc) + + +def llm_not_configured_handler(_request: Request, exc: LlmNotConfiguredError) -> JSONResponse: + """Map :class:`LlmNotConfiguredError` (no LLM provider configured) to 422.""" + logger.warning("LLM not configured: %s", exc.detail) + return _error_response(exc) + + # Register one handler per domain error type explicitly. Each handler reads the # exception's own status_code/detail, so no separate error->HTTP mapping table # is required. Most-specific types are registered first. @@ -234,6 +254,8 @@ async def invalid_api_key_handler(_request: Request, exc: InvalidApiKeyError) -> app.add_exception_handler(PromptAlreadyExistsError, prompt_already_exists_handler) app.add_exception_handler(PromptManagerUnavailableError, prompt_manager_unavailable_handler) app.add_exception_handler(InvalidApiKeyError, invalid_api_key_handler) +app.add_exception_handler(AuthenticationError, authentication_error_handler) +app.add_exception_handler(LlmNotConfiguredError, llm_not_configured_handler) app.add_exception_handler(DomainError, domain_error_handler) diff --git a/src/security.py b/src/security.py index c1155d9..f51354b 100644 --- a/src/security.py +++ b/src/security.py @@ -2,16 +2,30 @@ Validates the ``X-API-Key`` header against the configured master key. When the master key is empty (dev/test), authentication is disabled with a warning. + +The dual-auth ``verify_credentials`` dependency delegates to an injected +:class:`~src.domain.services.auth.auth_service.AuthService` (JWT bearer token ++ per-user API key) and sets the RLS contextvars on success. + +The WebSocket variant ``verify_credentials_ws`` accepts either +``Authorization: Bearer `` or ``X-API-Key: `` and rejects the +handshake with HTTP 401 on failure (sending a raw HTTP 401 response via the +ASGI ``websocket.http.response`` extension so the client receives a proper +Unauthorized status instead of the default 403). """ +import json import logging import secrets -from fastapi import Depends, WebSocket +from fastapi import Depends, Request, WebSocket from fastapi.security import APIKeyHeader +from src.domain.entities.auth.auth_context import AuthContext from src.domain.errors.messages import ErrorMessage -from src.domain.errors.security import InvalidApiKeyError +from src.domain.errors.security import AuthenticationError, InvalidApiKeyError +from src.domain.services.auth.auth_service import AuthService +from src.infrastructure.database.rls_context import current_auth_method, current_credential, current_user_id logger = logging.getLogger(__name__) @@ -27,8 +41,6 @@ async def _reject_ws_with_401(websocket: WebSocket, reason: str) -> None: websocket: The incoming WebSocket connection to reject. reason: The error detail to include in the JSON response body. """ - import json - body = json.dumps({"detail": reason}).encode("utf-8") await websocket.send( { @@ -62,8 +74,24 @@ class ComposableAgentsSecurity: api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - def __init__(self, master_key: str) -> None: + def __init__(self, master_key: str = "") -> None: self.master_key = master_key + # Dual-auth service (JWT + per-user API key). Injected via + # ``set_auth_service`` from the composition root; ``verify_credentials`` + # raises a clear RuntimeError if it is called before wiring. + self._auth_service: AuthService | None = None + + def set_auth_service(self, auth_service: AuthService) -> None: + """Inject the dual-auth :class:`AuthService`. + + Args: + auth_service: The wired ``AuthService`` (JWT port + API key repo). + """ + self._auth_service = auth_service + + def has_auth_service(self) -> bool: + """Return whether a dual-auth :class:`AuthService` is wired.""" + return self._auth_service is not None async def verify_api_key( self, @@ -122,3 +150,85 @@ async def verify_api_key_ws( await _reject_ws_with_401(websocket, str(ErrorMessage.API_KEY_UNAUTHORIZED)) return "" return api_key + + async def verify_credentials(self, request: Request) -> AuthContext: + """Dual-auth FastAPI dependency: JWT bearer token OR per-user API key. + + Reads the ``Authorization`` and ``X-API-Key`` headers, delegates to the + injected :class:`AuthService`, raises :class:`AuthenticationError` (401) + when no credential validates, and otherwise sets the RLS contextvars + (``current_user_id`` / ``current_credential``) and returns the resolved + :class:`AuthContext`. + + Args: + request: The incoming FastAPI request (headers are read from it). + + Returns: + The authenticated :class:`AuthContext`. + + Raises: + RuntimeError: If ``set_auth_service`` was never called (misuse). + AuthenticationError: If no credential could be validated (401). + """ + if self._auth_service is None: + raise RuntimeError("ComposableAgentsSecurity.verify_credentials called before set_auth_service") + + authorization = request.headers.get("authorization") + api_key = request.headers.get("x-api-key") + + ctx = await self._auth_service.authenticate( + authorization=authorization, + api_key=api_key, + ) + if ctx is None: + raise AuthenticationError(ErrorMessage.AUTH_INVALID_CREDENTIALS) + + # Wire the RLS contextvars for downstream SQLAlchemy event listeners. + current_user_id.set(ctx.user_id) + current_credential.set(ctx.raw_credential) + current_auth_method.set(ctx.method) + return ctx + + async def verify_credentials_ws(self, websocket: WebSocket) -> AuthContext | None: + """Dual-auth WebSocket dependency: JWT bearer token OR per-user API key. + + Reads the ``Authorization`` and ``X-API-Key`` headers from the + WebSocket handshake, delegates to the injected :class:`AuthService`, + and on success sets the RLS contextvars and returns the + :class:`AuthContext`. On failure, rejects the handshake with HTTP 401 + (via the ASGI ``websocket.http.response`` extension) and returns + ``None`` so the caller can simply ``return`` from the endpoint. + + Unlike the HTTP variant, this NEVER raises — WebSocket endpoints + cannot propagate an exception to a clean HTTP 401, so the rejection is + sent inline and ``None`` is returned. + + Args: + websocket: The incoming WebSocket connection. + + Returns: + The authenticated :class:`AuthContext` on success, or ``None`` on + failure (the handshake has already been rejected with 401). + """ + if self._auth_service is None: + # No dual-auth wired (dev/test, master-key only). Silent no-op so + # the caller can fall back to the master-key path without a spurious + # 401 having already been sent on the handshake. + return None + + authorization = websocket.headers.get("authorization") + api_key = websocket.headers.get("x-api-key") + + ctx = await self._auth_service.authenticate( + authorization=authorization, + api_key=api_key, + ) + if ctx is None: + await _reject_ws_with_401(websocket, str(ErrorMessage.AUTH_INVALID_CREDENTIALS)) + return None + + # Wire the RLS contextvars for downstream SQLAlchemy event listeners. + current_user_id.set(ctx.user_id) + current_credential.set(ctx.raw_credential) + current_auth_method.set(ctx.method) + return ctx diff --git a/tests/conftest.py b/tests/conftest.py index 466d857..152b361 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,11 @@ # src.dependencies) can validate. Tests that need a real engine use the # in-memory SQLite db_engine fixture, not this value. os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test") +# Provide auth-related env vars so Settings() accepts the new auth fields +# without requiring real Logto/JWKS/encryption configuration in tests. +os.environ.setdefault("LOGTO_URL", "http://test") +os.environ.setdefault("JWT_AUDIENCE", "test-audience") +os.environ.setdefault("SECRET_ENCRYPTION_KEY", "") from collections.abc import AsyncGenerator diff --git a/tests/unit/test_agent_config_user_isolation.py b/tests/unit/test_agent_config_user_isolation.py new file mode 100644 index 0000000..4a58191 --- /dev/null +++ b/tests/unit/test_agent_config_user_isolation.py @@ -0,0 +1,177 @@ +"""Tests for per-user isolation in :class:`PostgresAgentConfigRepository`. + +Same pattern as ``test_thread_user_isolation.py``: the repository reads +``current_user_id`` and filters / sets ``user_id`` accordingly. When the +contextvar is ``None`` no filter is applied (existing behaviour preserved). +""" + +from datetime import UTC, datetime + +import pytest + +from src.domain.entities.agent_config_metadata import AgentConfigMetadata +from src.domain.errors.agent import AgentNotFoundError +from src.infrastructure.database.rls_context import current_user_id +from src.infrastructure.postgres_repository.adapter import PostgresAgentConfigRepository + + +def _metadata(name: str = "test-agent") -> AgentConfigMetadata: + now = datetime.now(UTC) + return AgentConfigMetadata( + name=name, + model="claude-sonnet-4-5", + minio_path=f"agent-configs/{name}.yaml", + created_at=now, + updated_at=now, + ) + + +class TestAgentConfigUserIsolation: + """Per-user filtering driven by the ``current_user_id`` contextvar.""" + + @pytest.fixture + def repository(self, db_engine) -> PostgresAgentConfigRepository: + return PostgresAgentConfigRepository(engine=db_engine) + + async def test_save_sets_user_id_from_contextvar(self, repository): + # Arrange + tok = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + finally: + current_user_id.reset(tok) + + # Act — re-read under uA + tok2 = current_user_id.set("uA") + try: + refetched = await repository.get("agent-a") + finally: + current_user_id.reset(tok2) + + # Assert + assert refetched.user_id == "uA" + + async def test_list_under_uB_excludes_uA_config(self, repository): + # Arrange + tok = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + finally: + current_user_id.reset(tok) + + # Act + tok_b = current_user_id.set("uB") + try: + configs = await repository.list_all() + finally: + current_user_id.reset(tok_b) + + # Assert + assert configs == [] + + async def test_get_under_uB_raises_AgentNotFoundError_for_uA_config(self, repository): + # Arrange + tok = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + finally: + current_user_id.reset(tok) + + # Act / Assert + tok_b = current_user_id.set("uB") + try: + with pytest.raises(AgentNotFoundError): + await repository.get("agent-a") + finally: + current_user_id.reset(tok_b) + + async def test_exists_under_uB_returns_false_for_uA_config(self, repository): + # Arrange + tok = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + finally: + current_user_id.reset(tok) + + # Act + tok_b = current_user_id.set("uB") + try: + exists = await repository.exists("agent-a") + finally: + current_user_id.reset(tok_b) + + # Assert + assert exists is False + + async def test_exists_under_uA_returns_true_for_uA_config(self, repository): + # Arrange + tok = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + exists = await repository.exists("agent-a") + finally: + current_user_id.reset(tok) + + # Assert + assert exists is True + + async def test_delete_under_uB_raises_for_uA_config(self, repository): + # Arrange + tok = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + finally: + current_user_id.reset(tok) + + # Act / Assert + tok_b = current_user_id.set("uB") + try: + with pytest.raises(AgentNotFoundError): + await repository.delete("agent-a") + finally: + current_user_id.reset(tok_b) + + # The config is still visible to uA + tok_a = current_user_id.set("uA") + try: + refetched = await repository.get("agent-a") + finally: + current_user_id.reset(tok_a) + assert refetched.name == "agent-a" + + async def test_list_with_no_contextvar_returns_all(self, repository): + # Arrange — create under uA and uB + tok_a = current_user_id.set("uA") + try: + await repository.save(_metadata("agent-a")) + finally: + current_user_id.reset(tok_a) + + tok_b = current_user_id.set("uB") + try: + await repository.save(_metadata("agent-b")) + finally: + current_user_id.reset(tok_b) + + # Act — no contextvar + assert current_user_id.get() is None + configs = await repository.list_all() + + # Assert — all visible (no filter) + assert len(configs) == 2 + + async def test_save_with_no_contextvar_defaults_to_empty_user_id(self, repository): + # Arrange + assert current_user_id.get() is None + # Act + await repository.save(_metadata("agent-x")) + # Assert — re-read without contextvar + refetched = await repository.get("agent-x") + assert refetched.user_id == "" + + async def test_agent_config_metadata_entity_has_user_id_field(self): + # Arrange / Act + m = _metadata("agent-y") + # Assert + assert hasattr(m, "user_id") + assert m.user_id == "" diff --git a/tests/unit/test_agent_crud.py b/tests/unit/test_agent_crud.py index 9e3982f..e4da5d1 100644 --- a/tests/unit/test_agent_crud.py +++ b/tests/unit/test_agent_crud.py @@ -340,9 +340,7 @@ async def test_raises_config_error_when_name_mismatch( # New: description update + dependent agents invalidation on update. # ------------------------------------------------------------------ - async def test_updates_description_in_metadata( - self, use_case, mock_agent_config_repository, existing_metadata - ): + async def test_updates_description_in_metadata(self, use_case, mock_agent_config_repository, existing_metadata): """Should update the metadata description when the YAML provides one.""" # Arrange mock_agent_config_repository.get.return_value = existing_metadata @@ -375,15 +373,8 @@ async def test_invalidates_dependent_agents_referencing_updated_agent( " description: d\n" " agent_ref: X\n" ) - agent_b_yaml = ( - "name: B\n" - "model: claude-sonnet-4-5-20250929\n" - ) - agent_x_yaml = ( - "name: X\n" - "model: claude-sonnet-4-5-20250929\n" - 'system_prompt: "You are X."\n' - ) + agent_b_yaml = "name: B\nmodel: claude-sonnet-4-5-20250929\n" + agent_x_yaml = 'name: X\nmodel: claude-sonnet-4-5-20250929\nsystem_prompt: "You are X."\n' mock_agent_config_repository.get.return_value = AgentConfigMetadata( name="X", @@ -508,10 +499,7 @@ async def test_invalidates_dependent_agents_referencing_deleted_agent( " description: d\n" " agent_ref: X\n" ) - agent_b_yaml = ( - "name: B\n" - "model: claude-sonnet-4-5-20250929\n" - ) + agent_b_yaml = "name: B\nmodel: claude-sonnet-4-5-20250929\n" mock_agent_config_repository.get.return_value = AgentConfigMetadata( name="X", @@ -547,13 +535,32 @@ class TestGetAgentConfigUseCase: """Tests for GetAgentConfigUseCase.""" @pytest.fixture - def use_case(self, yaml_loader, mock_agent_config_store): + def use_case(self, yaml_loader, mock_agent_config_store, mock_agent_config_repository): return GetAgentConfigUseCase( config_loader=yaml_loader, config_store=mock_agent_config_store, + config_repository=mock_agent_config_repository, + ) + + @pytest.fixture + def repo_returns_test_agent(self, mock_agent_config_repository): + """Make the repository's RLS-filtered ``get`` return a test-agent metadata row.""" + from datetime import UTC, datetime + + from src.domain.entities.agent_config_metadata import AgentConfigMetadata + + mock_agent_config_repository.get.return_value = AgentConfigMetadata( + name="test-agent", + model="claude-sonnet-4-5-20250929", + minio_path="test-agent.yaml", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + updated_at=datetime(2026, 1, 1, tzinfo=UTC), + description=None, + user_id="", ) + return mock_agent_config_repository - async def test_returns_config_with_name_when_found(self, use_case, mock_agent_config_store): + async def test_returns_config_with_name_when_found(self, use_case, mock_agent_config_store, repo_returns_test_agent): """Should return parsed config with the agent name.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -564,7 +571,7 @@ async def test_returns_config_with_name_when_found(self, use_case, mock_agent_co # Assert assert result.name == "test-agent" - async def test_returns_config_with_model_when_found(self, use_case, mock_agent_config_store): + async def test_returns_config_with_model_when_found(self, use_case, mock_agent_config_store, repo_returns_test_agent): """Should return parsed config with the YAML model.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -575,7 +582,7 @@ async def test_returns_config_with_model_when_found(self, use_case, mock_agent_c # Assert assert result.model == "claude-sonnet-4-5-20250929" - async def test_returns_config_with_system_prompt_when_found(self, use_case, mock_agent_config_store): + async def test_returns_config_with_system_prompt_when_found(self, use_case, mock_agent_config_store, repo_returns_test_agent): """Should return parsed config with the YAML system_prompt.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -586,8 +593,8 @@ async def test_returns_config_with_system_prompt_when_found(self, use_case, mock # Assert assert result.system_prompt == "You are a test agent." - async def test_fetches_yaml_from_store_with_name(self, use_case, mock_agent_config_store): - """Should fetch the YAML from the store with the agent name.""" + async def test_fetches_yaml_from_store_with_name(self, use_case, mock_agent_config_store, repo_returns_test_agent): + """Should fetch the YAML from the store with the agent name resolved from metadata.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -597,6 +604,23 @@ async def test_fetches_yaml_from_store_with_name(self, use_case, mock_agent_conf # Assert mock_agent_config_store.get.assert_awaited_once_with("test-agent") + async def test_raises_agent_not_found_when_repository_says_not_owned( + self, use_case, mock_agent_config_repository, mock_agent_config_store + ): + """Should raise AgentNotFoundError (not fetch from MinIO) when the agent + is not visible to the current user (RLS-filtered repository returns 404). + This prevents cross-user leaks via the shared MinIO bucket. + """ + from src.domain.errors.agent import AgentNotFoundError + + mock_agent_config_repository.get.side_effect = AgentNotFoundError("not found") + + with pytest.raises(AgentNotFoundError): + await use_case.execute(name="someone-elsses-agent") + + # MinIO must never be consulted when ownership check fails. + mock_agent_config_store.get.assert_not_awaited() + class TestListAgentConfigsUseCase: """Tests for ListAgentConfigsUseCase.""" diff --git a/tests/unit/test_api_key_hasher.py b/tests/unit/test_api_key_hasher.py new file mode 100644 index 0000000..d17c3e8 --- /dev/null +++ b/tests/unit/test_api_key_hasher.py @@ -0,0 +1,89 @@ +"""Tests for the ApiKeyHasher utility. + +The hasher is a pure helper (no port) used by the auth domain service to hash +incoming API keys before looking them up in the repository and to generate new +plaintext keys with the ``cpk_`` prefix. Tests assert deterministic sha256 hex +output and the prefix/length/uniqueness guarantees of ``generate_key``. +""" + +import hashlib + +from src.infrastructure.auth.api_key_hasher import ApiKeyHasher + + +class TestApiKeyHasherHashKey: + """Tests for ``ApiKeyHasher.hash_key``.""" + + def test_hash_key_returns_deterministic_sha256_hex(self) -> None: + # Arrange + plaintext = "cpk_super-secret-value" + + # Act + result = ApiKeyHasher.hash_key(plaintext) + + # Assert + expected = hashlib.sha256(plaintext.encode()).hexdigest() + assert result == expected + + def test_hash_key_is_stable_across_calls(self) -> None: + # Arrange + plaintext = "cpk_stable-key" + + # Act + first = ApiKeyHasher.hash_key(plaintext) + second = ApiKeyHasher.hash_key(plaintext) + + # Assert + assert first == second + + def test_hash_key_differs_for_different_inputs(self) -> None: + # Arrange + a = "cpk_one" + b = "cpk_two" + + # Act + hash_a = ApiKeyHasher.hash_key(a) + hash_b = ApiKeyHasher.hash_key(b) + + # Assert + assert hash_a != hash_b + + def test_hash_key_returns_64_char_hex_string(self) -> None: + # Arrange + plaintext = "cpk_length-check" + + # Act + result = ApiKeyHasher.hash_key(plaintext) + + # Assert + assert len(result) == 64 + assert all(c in "0123456789abcdef" for c in result) + + +class TestApiKeyHasherGenerateKey: + """Tests for ``ApiKeyHasher.generate_key``.""" + + def test_generate_key_starts_with_cpk_prefix(self) -> None: + # Act + key = ApiKeyHasher.generate_key() + + # Assert + assert key.startswith("cpk_") + + def test_generate_key_is_long_enough(self) -> None: + # Act + key = ApiKeyHasher.generate_key() + + # Assert — ``cpk_`` prefix (4) + at least 32 urlsafe chars + assert len(key) > 40 + + def test_generate_key_is_unique_across_many_calls(self) -> None: + # Arrange + keys: set[str] = set() + + # Act + for _ in range(1000): + keys.add(ApiKeyHasher.generate_key()) + + # Assert + assert len(keys) == 1000 diff --git a/tests/unit/test_api_key_repository.py b/tests/unit/test_api_key_repository.py new file mode 100644 index 0000000..eaec3ba --- /dev/null +++ b/tests/unit/test_api_key_repository.py @@ -0,0 +1,243 @@ +"""Tests for the PostgresApiKeyRepository against a real in-memory SQLite engine. + +These tests drive the "API keys per user" layer (TDD red phase). They exercise +the real :class:`PostgresApiKeyRepository` adapter against the shared in-memory +SQLite ``db_engine`` fixture — no mocks on internal components. + +The ORM model (``src.infrastructure.database.models.api_key.ApiKeyModel``), the +extended port (``src.domain.ports.auth.api_key_repository.ApiKeyRepository``) +and the adapter (``src.infrastructure.postgres_api_key.adapter.PostgresApiKeyRepository``) +do not exist yet, so these tests fail at import until the implementation is +added in the green phase. +""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities.auth.api_key import ApiKeyView +from src.domain.errors.security import ApiKeyNotFoundError +from src.infrastructure.auth.api_key_hasher import ApiKeyHasher +from src.infrastructure.database.models.api_key import ApiKeyModel +from src.infrastructure.postgres_api_key.adapter import PostgresApiKeyRepository + +_USER_A = "user-aaa" +_USER_B = "user-bbb" + + +@pytest.fixture +async def api_key_repo(db_engine) -> PostgresApiKeyRepository: + """Provide a real PostgresApiKeyRepository backed by in-memory SQLite.""" + return PostgresApiKeyRepository(engine=db_engine) + + +async def _insert_key( + db_engine, + *, + user_id: str, + name: str, + plaintext: str, + revoked: bool = False, +) -> str: + """Insert an ApiKeyModel row directly and return its id.""" + key_id = uuid4().hex + key_hash = ApiKeyHasher.hash_key(plaintext) + key_prefix = plaintext[:10] + now = datetime.now(UTC) + async with AsyncSession(db_engine, expire_on_commit=False) as session: + session.add( + ApiKeyModel( + id=key_id, + user_id=user_id, + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + revoked_at=now if revoked else None, + last_used_at=None, + created_at=now, + ) + ) + await session.commit() + return key_id + + +class TestCreateApiKey: + """Tests for ``PostgresApiKeyRepository.create``.""" + + async def test_create_returns_key_id_and_persists_row(self, api_key_repo, db_engine): + # Arrange + plaintext = ApiKeyHasher.generate_key() + key_hash = ApiKeyHasher.hash_key(plaintext) + key_prefix = plaintext[:10] + + # Act + key_id = await api_key_repo.create( + user_id=_USER_A, + name="my-key", + key_hash=key_hash, + key_prefix=key_prefix, + ) + + # Assert — returned id is a uuid hex + assert isinstance(key_id, str) + assert len(key_id) == 36 or len(key_id) == 32 # uuid4 hex (with/without dashes) + + # Assert — row exists with correct fields + async with AsyncSession(db_engine, expire_on_commit=False) as session: + model = await session.get(ApiKeyModel, key_id) + assert model is not None + assert model.user_id == _USER_A + assert model.name == "my-key" + assert model.key_hash == key_hash + assert model.key_prefix == key_prefix + assert model.revoked_at is None + assert model.last_used_at is None + assert model.created_at is not None + + +class TestFindActiveByHash: + """Tests for ``PostgresApiKeyRepository.find_active_by_hash``.""" + + async def test_returns_user_id_and_key_id_for_active_key(self, api_key_repo, db_engine): + # Arrange + plaintext = ApiKeyHasher.generate_key() + key_id = await _insert_key(db_engine, user_id=_USER_A, name="k", plaintext=plaintext) + key_hash = ApiKeyHasher.hash_key(plaintext) + + # Act + result = await api_key_repo.find_active_by_hash(key_hash) + + # Assert + assert result is not None + assert result[0] == _USER_A + assert result[1] == key_id + + async def test_returns_none_for_unknown_hash(self, api_key_repo): + # Act + result = await api_key_repo.find_active_by_hash("0" * 64) + + # Assert + assert result is None + + async def test_returns_none_for_revoked_key(self, api_key_repo, db_engine): + # Arrange + plaintext = ApiKeyHasher.generate_key() + await _insert_key(db_engine, user_id=_USER_A, name="k", plaintext=plaintext, revoked=True) + key_hash = ApiKeyHasher.hash_key(plaintext) + + # Act + result = await api_key_repo.find_active_by_hash(key_hash) + + # Assert + assert result is None + + +class TestListByUser: + """Tests for ``PostgresApiKeyRepository.list_by_user``.""" + + async def test_returns_all_keys_for_user_sorted_by_created_at_desc(self, api_key_repo, db_engine): + # Arrange — two active keys + one revoked key for user A + await _insert_key(db_engine, user_id=_USER_A, name="first", plaintext="cpk_aaaaaaaa1") + # Bump created_at of k2 to be later than k1 by updating it directly. + k2 = await _insert_key(db_engine, user_id=_USER_A, name="second", plaintext="cpk_aaaaaaaa2") + later = datetime.now(UTC) + timedelta(days=1) + async with AsyncSession(db_engine, expire_on_commit=False) as session: + await session.execute(update(ApiKeyModel).where(ApiKeyModel.id == k2).values(created_at=later)) + await session.commit() + await _insert_key(db_engine, user_id=_USER_A, name="revoked", plaintext="cpk_aaaaaaaa3", revoked=True) + + # Act + result = await api_key_repo.list_by_user(_USER_A) + + # Assert + assert len(result) == 3 + assert all(isinstance(v, ApiKeyView) for v in result) + # Sorted by created_at desc — k2 (later) first, then the other two. + assert result[0].id == k2 + # Does NOT include the hash. + assert not hasattr(result[0], "key_hash") + + async def test_returns_empty_list_for_unknown_user(self, api_key_repo): + # Act + result = await api_key_repo.list_by_user("nobody") + + # Assert + assert result == [] + + async def test_does_not_leak_other_users_keys(self, api_key_repo, db_engine): + # Arrange + await _insert_key(db_engine, user_id=_USER_A, name="a", plaintext="cpk_bbbbbbbbb1") + await _insert_key(db_engine, user_id=_USER_B, name="b", plaintext="cpk_bbbbbbbbb2") + + # Act + result = await api_key_repo.list_by_user(_USER_A) + + # Assert — only user A's keys are returned + assert len(result) == 1 + assert result[0].name == "a" + + +class TestRevoke: + """Tests for ``PostgresApiKeyRepository.revoke``.""" + + async def test_revoke_sets_revoked_at_for_existing_key(self, api_key_repo, db_engine): + # Arrange + plaintext = ApiKeyHasher.generate_key() + key_id = await _insert_key(db_engine, user_id=_USER_A, name="k", plaintext=plaintext) + + # Act + await api_key_repo.revoke(user_id=_USER_A, key_id=key_id) + + # Assert + async with AsyncSession(db_engine, expire_on_commit=False) as session: + model = await session.get(ApiKeyModel, key_id) + assert model.revoked_at is not None + + async def test_revoke_unknown_key_id_raises_api_key_not_found(self, api_key_repo): + # Act & Assert + with pytest.raises(ApiKeyNotFoundError): + await api_key_repo.revoke(user_id=_USER_A, key_id=uuid4().hex) + + async def test_revoke_other_user_key_raises_api_key_not_found(self, api_key_repo, db_engine): + # Arrange — a key owned by user B + plaintext = ApiKeyHasher.generate_key() + key_id = await _insert_key(db_engine, user_id=_USER_B, name="k", plaintext=plaintext) + + # Act & Assert — user A cannot revoke user B's key + with pytest.raises(ApiKeyNotFoundError): + await api_key_repo.revoke(user_id=_USER_A, key_id=key_id) + + async def test_revoke_already_revoked_key_is_idempotent_success(self, api_key_repo, db_engine): + # Arrange + plaintext = ApiKeyHasher.generate_key() + key_id = await _insert_key(db_engine, user_id=_USER_A, name="k", plaintext=plaintext) + + # Act — revoke twice; the second call is a no-op success + await api_key_repo.revoke(user_id=_USER_A, key_id=key_id) + await api_key_repo.revoke(user_id=_USER_A, key_id=key_id) + + # Assert + async with AsyncSession(db_engine, expire_on_commit=False) as session: + model = await session.get(ApiKeyModel, key_id) + assert model.revoked_at is not None + + +class TestTouchLastUsed: + """Tests for ``PostgresApiKeyRepository.touch_last_used``.""" + + async def test_touch_last_used_sets_last_used_at(self, api_key_repo, db_engine): + # Arrange + plaintext = ApiKeyHasher.generate_key() + key_id = await _insert_key(db_engine, user_id=_USER_A, name="k", plaintext=plaintext) + + # Act + await api_key_repo.touch_last_used(key_id) + + # Assert + async with AsyncSession(db_engine, expire_on_commit=False) as session: + result = await session.execute(select(ApiKeyModel.last_used_at).where(ApiKeyModel.id == key_id)) + last_used = result.scalar_one() + assert last_used is not None diff --git a/tests/unit/test_api_key_routes.py b/tests/unit/test_api_key_routes.py new file mode 100644 index 0000000..d83cc4d --- /dev/null +++ b/tests/unit/test_api_key_routes.py @@ -0,0 +1,245 @@ +"""End-to-end tests for the ``/api/v1/api-keys`` router. + +Builds a minimal FastAPI app with the ``api_keys`` router and overrides the +``get_current_user_id`` dependency to return a fixed user id. Uses +``httpx.ASGITransport`` + ``AsyncClient`` so no HTTP server is started. + +The router (``src.application.routes.api_keys``), the +``get_current_user_id`` dependency (``src.dependencies``), the request DTO +(``src.application.requests.api_key``) and the exception types do not exist +yet, so these tests fail at import until the green-phase implementation. +""" + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +from src.application.routes.api_keys import router as api_keys_router +from src.dependencies import ( + get_create_api_key_use_case, + get_current_user_id, + get_list_api_keys_use_case, + get_revoke_api_key_use_case, +) +from src.domain.errors.security import ApiKeyError, ApiKeyNotFoundError, AuthenticationError +from src.infrastructure.postgres_api_key.adapter import PostgresApiKeyRepository + +_USER_ID = "user-test-123" + + +def _build_app(repo: PostgresApiKeyRepository, *, user_id: str | None = _USER_ID) -> FastAPI: + """Build a minimal FastAPI app with the api_keys router wired to a real repo. + + When ``user_id`` is ``None``, the ``get_current_user_id`` override raises + ``AuthenticationError`` so the 401 path can be exercised. + """ + app = FastAPI() + app.include_router(api_keys_router) + + if user_id is None: + + def _raise() -> str: + raise AuthenticationError("Invalid or missing credentials") + + app.dependency_overrides[get_current_user_id] = _raise + else: + app.dependency_overrides[get_current_user_id] = lambda: user_id + + app.dependency_overrides[get_create_api_key_use_case] = lambda: _make_create(repo) + app.dependency_overrides[get_list_api_keys_use_case] = lambda: _make_list(repo) + app.dependency_overrides[get_revoke_api_key_use_case] = lambda: _make_revoke(repo) + + _register_handlers(app) + return app + + +def _make_create(repo): + from src.application.use_cases.api_key.create_api_key import CreateApiKeyUseCase + + return CreateApiKeyUseCase(repo=repo) + + +def _make_list(repo): + from src.application.use_cases.api_key.list_api_keys import ListApiKeysUseCase + + return ListApiKeysUseCase(repo=repo) + + +def _make_revoke(repo): + from src.application.use_cases.api_key.revoke_api_key import RevokeApiKeyUseCase + + return RevokeApiKeyUseCase(repo=repo) + + +def _register_handlers(app: FastAPI) -> None: + """Register exception handlers matching the production handler shape.""" + + async def _auth_err(_req, exc: AuthenticationError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + async def _api_key_err(_req, exc: ApiKeyError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + async def _not_found_err(_req, exc: ApiKeyNotFoundError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + app.add_exception_handler(AuthenticationError, _auth_err) + app.add_exception_handler(ApiKeyError, _api_key_err) + app.add_exception_handler(ApiKeyNotFoundError, _not_found_err) + + +@pytest.fixture +async def repo(db_engine) -> PostgresApiKeyRepository: + return PostgresApiKeyRepository(engine=db_engine) + + +@pytest.fixture +def app(repo) -> FastAPI: + return _build_app(repo, user_id=_USER_ID) + + +@pytest.fixture +def auth_failure_app(repo) -> FastAPI: + return _build_app(repo, user_id=None) + + +class TestCreateApiKeyRoute: + """``POST /api/v1/api-keys``.""" + + async def test_post_returns_201_with_plaintext_and_persists(self, app, repo, db_engine): + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/api-keys", json={"name": "my key"}) + + # Assert — response shape + assert resp.status_code == 201 + body = resp.json() + assert body["name"] == "my key" + assert body["plaintext"].startswith("cpk_") + assert body["key_prefix"] == body["plaintext"][:10] + assert body["id"] + assert body["created_at"] + + # Assert — row persisted in DB + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession + + from src.infrastructure.database.models.api_key import ApiKeyModel + + async with AsyncSession(db_engine, expire_on_commit=False) as session: + row = await session.execute(select(ApiKeyModel).where(ApiKeyModel.id == body["id"])) + assert row.scalar_one() is not None + + async def test_post_empty_name_returns_422(self, app): + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/api-keys", json={"name": ""}) + + # Assert — pydantic min_length=1 yields 422 from FastAPI validation + assert resp.status_code == 422 + + async def test_post_missing_name_field_returns_422(self, app): + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/api-keys", json={}) + + # Assert + assert resp.status_code == 422 + + async def test_post_unauthenticated_returns_401(self, auth_failure_app): + # Act + transport = ASGITransport(app=auth_failure_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/api-keys", json={"name": "x"}) + + # Assert + assert resp.status_code == 401 + + +class TestListApiKeysRoute: + """``GET /api/v1/api-keys``.""" + + async def test_get_returns_200_list_after_create(self, app): + # Arrange — create one key first + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.post("/api/v1/api-keys", json={"name": "first"}) + + # Act + resp = await client.get("/api/v1/api-keys") + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) == 1 + assert body[0]["name"] == "first" + assert "key_hash" not in body[0] + + async def test_get_unauthenticated_returns_401(self, auth_failure_app): + # Act + transport = ASGITransport(app=auth_failure_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/api-keys") + + # Assert + assert resp.status_code == 401 + + +class TestRevokeApiKeyRoute: + """``DELETE /api/v1/api-keys/{key_id}``.""" + + async def test_delete_returns_204(self, app): + # Arrange — create a key + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + created = await client.post("/api/v1/api-keys", json={"name": "to-revoke"}) + key_id = created.json()["id"] + + # Act + resp = await client.delete(f"/api/v1/api-keys/{key_id}") + + # Assert + assert resp.status_code == 204 + + async def test_delete_already_revoked_returns_204_idempotent(self, app): + # Arrange + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + created = await client.post("/api/v1/api-keys", json={"name": "k"}) + key_id = created.json()["id"] + + # Act — delete twice + first = await client.delete(f"/api/v1/api-keys/{key_id}") + second = await client.delete(f"/api/v1/api-keys/{key_id}") + + # Assert — both succeed (idempotent revoke) + assert first.status_code == 204 + assert second.status_code == 204 + + async def test_delete_never_existed_returns_404(self, app): + # Arrange + from uuid import uuid4 + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + # Act + resp = await client.delete(f"/api/v1/api-keys/{uuid4().hex}") + + # Assert + assert resp.status_code == 404 + + async def test_delete_unauthenticated_returns_401(self, auth_failure_app): + # Act + from uuid import uuid4 + + transport = ASGITransport(app=auth_failure_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.delete(f"/api/v1/api-keys/{uuid4().hex}") + + # Assert + assert resp.status_code == 401 diff --git a/tests/unit/test_api_key_use_cases.py b/tests/unit/test_api_key_use_cases.py new file mode 100644 index 0000000..6062ef9 --- /dev/null +++ b/tests/unit/test_api_key_use_cases.py @@ -0,0 +1,169 @@ +"""Tests for the API-key management use cases. + +Uses the real :class:`PostgresApiKeyRepository` (via the shared in-memory +SQLite ``db_engine`` fixture). No internal component is mocked. + +The use cases (``CreateApiKeyUseCase``, ``ListApiKeysUseCase``, +``RevokeApiKeyUseCase``), the entities (``CreatedApiKey``, ``ApiKeyView``) and +the domain error (``ApiKeyError``) do not exist yet, so these tests fail at +import until the green-phase implementation. +""" + +import hashlib + +import pytest + +from src.application.use_cases.api_key.create_api_key import CreateApiKeyUseCase +from src.application.use_cases.api_key.list_api_keys import ListApiKeysUseCase +from src.application.use_cases.api_key.revoke_api_key import RevokeApiKeyUseCase +from src.domain.entities.auth.api_key import ApiKeyView, CreatedApiKey +from src.domain.errors.security import ApiKeyError, ApiKeyNotFoundError +from src.infrastructure.database.models.api_key import ApiKeyModel +from src.infrastructure.postgres_api_key.adapter import PostgresApiKeyRepository + +_USER_A = "user-aaa" +_USER_B = "user-bbb" + + +@pytest.fixture +async def api_key_repo(db_engine) -> PostgresApiKeyRepository: + """Provide a real PostgresApiKeyRepository backed by in-memory SQLite.""" + return PostgresApiKeyRepository(engine=db_engine) + + +@pytest.fixture +def create_use_case(api_key_repo) -> CreateApiKeyUseCase: + return CreateApiKeyUseCase(repo=api_key_repo) + + +@pytest.fixture +def list_use_case(api_key_repo) -> ListApiKeysUseCase: + return ListApiKeysUseCase(repo=api_key_repo) + + +@pytest.fixture +def revoke_use_case(api_key_repo) -> RevokeApiKeyUseCase: + return RevokeApiKeyUseCase(repo=api_key_repo) + + +class TestCreateApiKeyUseCase: + """Tests for ``CreateApiKeyUseCase.execute``.""" + + async def test_returns_created_api_key_with_cpk_prefix_and_prefix_field(self, create_use_case): + # Act + result = await create_use_case.execute(user_id=_USER_A, name="my key") + + # Assert + assert isinstance(result, CreatedApiKey) + assert result.name == "my key" + assert result.plaintext.startswith("cpk_") + assert result.key_prefix == result.plaintext[:10] + assert result.id # non-empty uuid + assert result.created_at is not None + + async def test_persists_row_with_sha256_of_plaintext(self, create_use_case, db_engine): + # Act + result = await create_use_case.execute(user_id=_USER_A, name="my key") + + # Assert — the stored hash equals sha256(plaintext) + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession + + async with AsyncSession(db_engine, expire_on_commit=False) as session: + row = await session.execute(select(ApiKeyModel).where(ApiKeyModel.id == result.id)) + model = row.scalar_one() + expected_hash = hashlib.sha256(result.plaintext.encode()).hexdigest() + assert model.key_hash == expected_hash + assert model.user_id == _USER_A + assert model.name == "my key" + assert model.key_prefix == result.plaintext[:10] + + async def test_empty_name_raises_api_key_error(self, create_use_case): + # Act & Assert + with pytest.raises(ApiKeyError): + await create_use_case.execute(user_id=_USER_A, name="") + + async def test_whitespace_name_raises_api_key_error(self, create_use_case): + # Act & Assert + with pytest.raises(ApiKeyError): + await create_use_case.execute(user_id=_USER_A, name=" ") + + +class TestListApiKeysUseCase: + """Tests for ``ListApiKeysUseCase.execute``.""" + + async def test_returns_api_key_views_without_hash(self, list_use_case, create_use_case): + # Arrange + await create_use_case.execute(user_id=_USER_A, name="first") + + # Act + result = await list_use_case.execute(user_id=_USER_A) + + # Assert + assert len(result) == 1 + assert all(isinstance(v, ApiKeyView) for v in result) + assert not hasattr(result[0], "key_hash") + assert result[0].name == "first" + + async def test_includes_revoked_keys(self, list_use_case, create_use_case, revoke_use_case): + # Arrange + created = await create_use_case.execute(user_id=_USER_A, name="to-revoke") + await revoke_use_case.execute(user_id=_USER_A, key_id=created.id) + await create_use_case.execute(user_id=_USER_A, name="active") + + # Act + result = await list_use_case.execute(user_id=_USER_A) + + # Assert — both the revoked and the active key appear + assert len(result) == 2 + names = {v.name for v in result} + assert names == {"to-revoke", "active"} + # The revoked one carries revoked_at + revoked_view = next(v for v in result if v.name == "to-revoke") + assert revoked_view.revoked_at is not None + + async def test_does_not_leak_other_users_keys(self, list_use_case, create_use_case): + # Arrange + await create_use_case.execute(user_id=_USER_A, name="a") + await create_use_case.execute(user_id=_USER_B, name="b") + + # Act + result = await list_use_case.execute(user_id=_USER_A) + + # Assert + assert len(result) == 1 + assert result[0].name == "a" + + +class TestRevokeApiKeyUseCase: + """Tests for ``RevokeApiKeyUseCase.execute``.""" + + async def test_revoke_sets_revoked_at(self, revoke_use_case, create_use_case, db_engine): + # Arrange + created = await create_use_case.execute(user_id=_USER_A, name="k") + + # Act + await revoke_use_case.execute(user_id=_USER_A, key_id=created.id) + + # Assert + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession + + async with AsyncSession(db_engine, expire_on_commit=False) as session: + row = await session.execute(select(ApiKeyModel.revoked_at).where(ApiKeyModel.id == created.id)) + assert row.scalar_one() is not None + + async def test_revoke_unknown_key_raises_not_found(self, revoke_use_case): + # Act & Assert + from uuid import uuid4 + + with pytest.raises(ApiKeyNotFoundError): + await revoke_use_case.execute(user_id=_USER_A, key_id=uuid4().hex) + + async def test_revoke_other_user_key_raises_not_found(self, revoke_use_case, create_use_case): + # Arrange — key owned by user B + created = await create_use_case.execute(user_id=_USER_B, name="k") + + # Act & Assert — user A cannot revoke it + with pytest.raises(ApiKeyNotFoundError): + await revoke_use_case.execute(user_id=_USER_A, key_id=created.id) diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py new file mode 100644 index 0000000..8738fe4 --- /dev/null +++ b/tests/unit/test_auth_service.py @@ -0,0 +1,171 @@ +"""Tests for the ``AuthService`` domain service. + +``AuthService`` orchestrates dual authentication (JWT bearer token vs API key) +and is the only component that knows the precedence rules between the two. It +depends on a ``JwtServicePort`` (external JWKS boundary → mocked) and an +``ApiKeyRepository`` port (implemented in a later layer → mocked here per spec). + +Tests cover: +- JWT path (valid token → AuthContext with method=jwt) +- JWT invalid (decode_token returns None → authenticate returns None) +- JWT missing prefix falls through (no api_key → None) +- API key path (valid hash lookup → AuthContext with method=api_key) +- API key revoked/unknown → None +- Both None → None +- JWT takes precedence over API key when both present +""" + +import hashlib +from unittest.mock import AsyncMock + +import pytest + +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.entities.user.user import User +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.domain.ports.auth.jwt_service import JwtServicePort +from src.domain.services.auth.auth_service import AuthService + + +class TestAuthServiceJwtPath: + """Tests for the JWT bearer token authentication path.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + mock = AsyncMock(spec=JwtServicePort) + mock.decode_token.return_value = User(sub="user-123", email="a@b.c") + return mock + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + return AsyncMock(spec=ApiKeyRepository) + + @pytest.fixture + def auth_service(self, jwt_port, api_key_repo) -> AuthService: + return AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + + async def test_jwt_valid_returns_auth_context_with_jwt_method(self, auth_service, jwt_port): + # Act + result = await auth_service.authenticate(authorization="Bearer tok", api_key=None) + + # Assert + assert result is not None + assert isinstance(result, AuthContext) + assert result.user_id == "user-123" + assert result.method == "jwt" + assert result.raw_credential == "tok" + jwt_port.decode_token.assert_awaited_once_with("tok") + + async def test_jwt_invalid_returns_none(self, auth_service, jwt_port): + # Arrange + jwt_port.decode_token.return_value = None + + # Act + result = await auth_service.authenticate(authorization="Bearer tok", api_key=None) + + # Assert + assert result is None + + async def test_jwt_missing_prefix_falls_through_to_none(self, auth_service): + # Arrange — "Token x" does not start with "Bearer " + # Act + result = await auth_service.authenticate(authorization="Token x", api_key=None) + + # Assert + assert result is None + + +class TestAuthServiceApiKeyPath: + """Tests for the X-API-Key authentication path.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + return AsyncMock(spec=JwtServicePort) + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + mock = AsyncMock(spec=ApiKeyRepository) + mock.find_active_by_hash.return_value = ("user-456", "key-id-1") + return mock + + @pytest.fixture + def auth_service(self, jwt_port, api_key_repo) -> AuthService: + return AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + + async def test_api_key_valid_returns_auth_context_with_api_key_method(self, auth_service, api_key_repo): + # Arrange + api_key = "cpk_xxx" + + # Act + result = await auth_service.authenticate(authorization=None, api_key=api_key) + + # Assert + assert result is not None + assert isinstance(result, AuthContext) + assert result.user_id == "user-456" + assert result.method == "api_key" + assert result.raw_credential == api_key + expected_hash = hashlib.sha256(api_key.encode()).hexdigest() + api_key_repo.find_active_by_hash.assert_awaited_once_with(expected_hash) + + async def test_api_key_revoked_or_unknown_returns_none(self, auth_service, api_key_repo): + # Arrange + api_key_repo.find_active_by_hash.return_value = None + + # Act + result = await auth_service.authenticate(authorization=None, api_key="cpk_wrong") + + # Assert + assert result is None + + async def test_api_key_lookup_uses_sha256_hex_of_plaintext(self, auth_service, api_key_repo): + # Arrange + api_key = "cpk_lookup-test" + + # Act + await auth_service.authenticate(authorization=None, api_key=api_key) + + # Assert + called_hash = api_key_repo.find_active_by_hash.await_args.args[0] + assert called_hash == hashlib.sha256(api_key.encode()).hexdigest() + assert len(called_hash) == 64 + + +class TestAuthServicePrecedenceAndEmpty: + """Tests for precedence rules and the empty-credentials case.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + mock = AsyncMock(spec=JwtServicePort) + mock.decode_token.return_value = User(sub="user-jwt") + return mock + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + mock = AsyncMock(spec=ApiKeyRepository) + mock.find_active_by_hash.return_value = ("user-api", "key-id-1") + return mock + + @pytest.fixture + def auth_service(self, jwt_port, api_key_repo) -> AuthService: + return AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + + async def test_both_none_returns_none(self, auth_service): + # Act + result = await auth_service.authenticate(authorization=None, api_key=None) + + # Assert + assert result is None + + async def test_jwt_takes_precedence_over_api_key(self, auth_service, jwt_port, api_key_repo): + # Arrange — both Authorization: Bearer and X-API-Key present + + # Act + result = await auth_service.authenticate(authorization="Bearer tok", api_key="cpk_xxx") + + # Assert + assert result is not None + assert result.method == "jwt" + assert result.user_id == "user-jwt" + jwt_port.decode_token.assert_awaited_once() + api_key_repo.find_active_by_hash.assert_not_awaited() diff --git a/tests/unit/test_env_utils_user_credentials.py b/tests/unit/test_env_utils_user_credentials.py new file mode 100644 index 0000000..35544dc --- /dev/null +++ b/tests/unit/test_env_utils_user_credentials.py @@ -0,0 +1,191 @@ +"""Tests for user-credential placeholder resolution in env_utils. + +The MCP credential propagation feature adds two placeholders resolved from +the RLS contextvars (``current_auth_method`` + ``current_credential``): + +* ``${USER_JWT}`` → raw JWT string when ``current_auth_method == "jwt"``, + else empty string. +* ``${USER_API_KEY}`` → raw API key when ``current_auth_method == "api_key"``, + else empty string. + +``resolve_all_vars`` resolves BOTH ``os.environ`` vars AND the user-credential +placeholders in a single pass. When the contextvars are unset (no auth +context), both placeholders resolve to empty strings. +""" + +import pytest + +from src.infrastructure.database.rls_context import current_auth_method, current_credential +from src.infrastructure.env_utils import resolve_all_vars + + +class TestResolveUserJwt: + """``${USER_JWT}`` resolution driven by ``current_auth_method`` + ``current_credential``.""" + + def test_resolves_to_credential_when_method_jwt(self) -> None: + # Arrange + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("tok123") + try: + # Act + result = resolve_all_vars("${USER_JWT}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "tok123" + + def test_resolves_to_empty_when_method_api_key(self) -> None: + # Arrange + tok_m = current_auth_method.set("api_key") + tok_c = current_credential.set("cpk_xyz") + try: + # Act + result = resolve_all_vars("${USER_JWT}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "" + + def test_resolves_to_empty_when_contextvars_unset(self) -> None: + # Arrange — defaults + assert current_auth_method.get() is None + assert current_credential.get() is None + + # Act + result = resolve_all_vars("${USER_JWT}") + + # Assert + assert result == "" + + +class TestResolveUserApiKey: + """``${USER_API_KEY}`` resolution.""" + + def test_resolves_to_credential_when_method_api_key(self) -> None: + # Arrange + tok_m = current_auth_method.set("api_key") + tok_c = current_credential.set("cpk_xyz") + try: + # Act + result = resolve_all_vars("${USER_API_KEY}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "cpk_xyz" + + def test_resolves_to_empty_when_method_jwt(self) -> None: + # Arrange + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("tok123") + try: + # Act + result = resolve_all_vars("${USER_API_KEY}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "" + + def test_resolves_to_empty_when_contextvars_unset(self) -> None: + # Arrange — defaults + assert current_auth_method.get() is None + + # Act + result = resolve_all_vars("${USER_API_KEY}") + + # Assert + assert result == "" + + +class TestResolveAllVarsMixed: + """``resolve_all_vars`` resolves both os.environ and user-credential placeholders.""" + + def test_resolves_os_env_and_user_jwt_together(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setenv("OPENROUTER_API_KEY", "or-abc") + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("tok123") + try: + # Act + result = resolve_all_vars("${OPENROUTER_API_KEY}/${USER_JWT}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "or-abc/tok123" + + def test_resolves_os_env_and_user_api_key_together(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setenv("BASE_URL", "http://x") + tok_m = current_auth_method.set("api_key") + tok_c = current_credential.set("cpk_1") + try: + # Act + result = resolve_all_vars("${BASE_URL}|${USER_API_KEY}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "http://x|cpk_1" + + def test_bearer_prefix_with_user_jwt(self) -> None: + # Arrange + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("tok123") + try: + # Act + result = resolve_all_vars("Bearer ${USER_JWT}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "Bearer tok123" + + def test_bearer_prefix_with_user_api_key(self) -> None: + # Arrange + tok_m = current_auth_method.set("api_key") + tok_c = current_credential.set("cpk_1") + try: + # Act + result = resolve_all_vars("Bearer ${USER_API_KEY}") + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + assert result == "Bearer cpk_1" + + def test_all_contextvars_unset_yields_empty_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setenv("X", "ok") + assert current_auth_method.get() is None + + # Act + result = resolve_all_vars("${X}|${USER_JWT}|${USER_API_KEY}") + + # Assert + assert result == "ok||" + + def test_plain_string_unchanged(self) -> None: + # Act + result = resolve_all_vars("plain-value") + + # Assert + assert result == "plain-value" + + def test_empty_string_returns_empty(self) -> None: + # Act + result = resolve_all_vars("") + + # Assert + assert result == "" diff --git a/tests/unit/test_factory_llm_per_user.py b/tests/unit/test_factory_llm_per_user.py new file mode 100644 index 0000000..be849e4 --- /dev/null +++ b/tests/unit/test_factory_llm_per_user.py @@ -0,0 +1,117 @@ +"""Tests for per-user LLM credentials in the DeepAgent factory. + +Verifies that ``create_agent_from_config`` builds a ``ChatOpenAI`` instance +with the user's ``base_url`` / ``api_key`` when an LLM credentials resolver is +provided AND the ``current_user_id`` contextvar is set. Falls back to the env +string-based model when no resolver / no contextvar (existing tests behaviour). + +The deepagents ``create_deep_agent`` boundary is mocked so no real LLM is built. +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langchain_openai import ChatOpenAI + +from src.domain.entities.agent_config import AgentConfig +from src.domain.errors.llm import LlmNotConfiguredError +from src.infrastructure.database.rls_context import current_user_id +from src.infrastructure.deepagent.factory import create_agent_from_config + + +def _captured_model(mock_create: MagicMock) -> Any: + """Return the ``model`` kwarg passed to ``create_deep_agent``.""" + return mock_create.call_args.kwargs["model"] + + +class TestPerUserLlmCredentials: + """When a resolver returns credentials and a user is set, build ChatOpenAI.""" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_resolver_returns_credentials_builds_chat_openai_instance(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test-agent", model="gpt-4o-mini") + token = current_user_id.set("u1") + try: + resolver = AsyncMock(return_value=("https://api.openai.com/v1", "sk-test")) + + # Act + await create_agent_from_config(config, llm_credentials_resolver=resolver) + + # Assert + kwargs = mock_create.call_args.kwargs + model = kwargs["model"] + assert isinstance(model, ChatOpenAI) + assert model.openai_api_base == "https://api.openai.com/v1" + # api_key stored as SecretStr — check the value + assert model.openai_api_key is not None + assert model.openai_api_key.get_secret_value() == "sk-test" + assert model.model == "gpt-4o-mini" + finally: + current_user_id.reset(token) + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_resolver_returns_none_raises_llm_not_configured(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test-agent") + token = current_user_id.set("u1") + try: + resolver = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(LlmNotConfiguredError): + await create_agent_from_config(config, llm_credentials_resolver=resolver) + finally: + current_user_id.reset(token) + + +class TestEnvFallback: + """When no resolver is provided OR no user contextvar, fall back to env string model.""" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_no_resolver_keeps_string_model(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test-agent") + token = current_user_id.set("u1") + try: + # Act — no resolver provided + await create_agent_from_config(config) + + # Assert — model is still the string + kwargs = mock_create.call_args.kwargs + assert isinstance(kwargs["model"], str) + assert kwargs["model"] == "claude-sonnet-4-5-20250929" + finally: + current_user_id.reset(token) + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_no_user_contextvar_keeps_string_model_even_with_resolver(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test-agent") + # current_user_id is None by default in tests + assert current_user_id.get() is None + resolver = AsyncMock(return_value=("https://x", "sk-test")) + + # Act + await create_agent_from_config(config, llm_credentials_resolver=resolver) + + # Assert — string fallback because no user contextvar + kwargs = mock_create.call_args.kwargs + assert isinstance(kwargs["model"], str) + # The resolver was NOT called since there's no user + resolver.assert_not_awaited() + + +class TestResolverSignature: + """The factory accepts an optional ``llm_credentials_resolver`` callable.""" + + def test_signature_accepts_resolver(self): + import inspect + + sig = inspect.signature(create_agent_from_config) + assert "llm_credentials_resolver" in sig.parameters diff --git a/tests/unit/test_fernet_crypto.py b/tests/unit/test_fernet_crypto.py new file mode 100644 index 0000000..fdad2ad --- /dev/null +++ b/tests/unit/test_fernet_crypto.py @@ -0,0 +1,78 @@ +"""Tests for the FernetCrypto helper. + +Pure unit tests (no I/O, no DB). The class does not exist yet, so these tests +fail at import until the green-phase implementation is added. +""" + +import pytest +from cryptography.fernet import InvalidToken + +from src.infrastructure.crypto.fernet_crypto import FernetCrypto + +# A fixed Fernet key (generated once) used across these tests. +_TEST_KEY = "Yr5R5-6lRUaxEwZWVysIaFs5POHcLps2OZViwWAscaU=" + + +class TestFernetCryptoRoundtrip: + """encrypt / decrypt roundtrip preserves plaintext.""" + + def test_roundtrip_simple(self): + crypto = FernetCrypto(key=_TEST_KEY) + token = crypto.encrypt("hello world") + assert isinstance(token, str) + assert crypto.decrypt(token) == "hello world" + + def test_roundtrip_api_key(self): + crypto = FernetCrypto(key=_TEST_KEY) + plaintext = "sk-test-123456789" + token = crypto.encrypt(plaintext) + assert token != plaintext # actually encrypted + assert crypto.decrypt(token) == plaintext + + def test_roundtrip_empty_string(self): + crypto = FernetCrypto(key=_TEST_KEY) + token = crypto.encrypt("") + assert crypto.decrypt(token) == "" + + def test_different_plaintexts_yield_different_tokens(self): + crypto = FernetCrypto(key=_TEST_KEY) + t1 = crypto.encrypt("alpha") + t2 = crypto.encrypt("beta") + assert t1 != t2 + + def test_same_plaintext_twice_yields_different_tokens(self): + """Fernet embeds a random IV / timestamp so two encryptions differ.""" + crypto = FernetCrypto(key=_TEST_KEY) + t1 = crypto.encrypt("same") + t2 = crypto.encrypt("same") + assert t1 != t2 + assert crypto.decrypt(t1) == crypto.decrypt(t2) == "same" + + +class TestFernetCryptoWrongToken: + """decrypt raises InvalidToken on tampered / wrong-key tokens.""" + + def test_decrypt_wrong_token_raises_invalid_token(self): + crypto = FernetCrypto(key=_TEST_KEY) + with pytest.raises(InvalidToken): + crypto.decrypt("not-a-valid-fernet-token") + + def test_decrypt_token_from_other_key_raises(self): + crypto1 = FernetCrypto(key=_TEST_KEY) + other_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + crypto2 = FernetCrypto(key=other_key) + token = crypto1.encrypt("secret") + with pytest.raises(InvalidToken): + crypto2.decrypt(token) + + +class TestFernetCryptoEmptyKey: + """An empty key is invalid in production wiring (fail-fast).""" + + def test_empty_key_raises_value_error(self): + with pytest.raises(ValueError): + FernetCrypto(key="") + + def test_whitespace_only_key_raises_value_error(self): + with pytest.raises(ValueError): + FernetCrypto(key=" ") diff --git a/tests/unit/test_jwt_adapter.py b/tests/unit/test_jwt_adapter.py new file mode 100644 index 0000000..bea4b5a --- /dev/null +++ b/tests/unit/test_jwt_adapter.py @@ -0,0 +1,343 @@ +"""Tests for the ``JwtAdapter`` infrastructure component. + +Mirrors the pickpro-back JWT adapter pattern: the adapter fetches the JWKS +document via ``httpx.AsyncClient`` and caches it in an in-memory +``cachetools.TTLCache`` (single entry, TTL 300s). On any decode error it +returns ``None`` and logs — it never raises. Algorithms accepted are +``RS256``, ``ES256`` and ``ES384``; no issuer validation. + +The external boundary (JWKS HTTP fetch) is mocked by replacing the adapter's +``_jwks_http_client`` with an ``AsyncMock``; ``PyJWK.from_dict``, +``jwt.get_unverified_header`` and ``jwt.decode`` are patched on the adapter +module. The adapter itself (internal component) is instantiated for real. +""" + +import logging +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import jwt +import pytest +from jwt.exceptions import PyJWKClientError + +from src.domain.entities.user.user import User +from src.domain.logging.messages import LogMessage +from src.infrastructure.auth.jwt_adapter import JwtAdapter + +AUDIENCE = "test-audience" +JWKS_URL = "http://test/oidc/jwks" + + +def _valid_claims() -> dict: + """Return a minimal valid JWT payload (only fields User.model_validate needs).""" + now = int(time.time()) + return { + "sub": "user-abc-123", + "email": "alice@example.com", + "name": "Alice Martin", + "username": "alice", + "created_at": now, + "updated_at": now, + } + + +def _jwks_dict() -> dict: + return {"keys": [{"kid": "test-kid", "kty": "RSA", "n": "x", "e": "AQAB"}]} + + +class TestJwtAdapter: + """Tests for ``JwtAdapter.decode_token``.""" + + @pytest.fixture + def adapter(self) -> JwtAdapter: + """A real ``JwtAdapter`` with a JWKS URL + audience (cache enabled).""" + return JwtAdapter(jwks_url=JWKS_URL, audience=AUDIENCE) + + @pytest.fixture(autouse=False) + def _stub_signing_key(self, adapter: JwtAdapter, request: pytest.FixtureRequest) -> None: + """Stub JWKS fetch + signing key resolution. + + Replaces the adapter's ``_jwks_http_client`` with an ``AsyncMock`` whose + ``get`` returns a fake JWKS response. Patches ``PyJWK.from_dict`` to + return a mock signing key and ``jwt.get_unverified_header`` to return a + fake header with ``kid=test-kid``. ``jwt.decode`` is left for the + individual test to patch. + """ + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = _jwks_dict() + + adapter._jwks_http_client = AsyncMock() + adapter._jwks_http_client.get = AsyncMock(return_value=mock_response) + + patcher_jwk = patch("src.infrastructure.auth.jwt_adapter.PyJWK.from_dict") + mock_jwk = patcher_jwk.start() + mock_key = MagicMock() + mock_key.key = "mock-key" + mock_jwk.return_value = mock_key + request.addfinalizer(patcher_jwk.stop) + + patcher_header = patch( + "src.infrastructure.auth.jwt_adapter.jwt.get_unverified_header", + return_value={"kid": "test-kid"}, + ) + patcher_header.start() + request.addfinalizer(patcher_header.stop) + + # -- No JWKS configured ----------------------------------------------------- + + async def test_no_jwks_url_returns_none(self) -> None: + # Arrange — adapter without jwks_url has no HTTP client + adapter = JwtAdapter(jwks_url="", audience=AUDIENCE) + + # Act + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + # -- Valid decode ----------------------------------------------------------- + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_valid_token_returns_user_with_sub(self, adapter: JwtAdapter) -> None: + # Arrange + claims = _valid_claims() + + # Act + with patch("src.infrastructure.auth.jwt_adapter.jwt.decode", return_value=claims): + result = await adapter.decode_token("some-token") + + # Assert + assert result is not None + assert isinstance(result, User) + assert result.sub == claims["sub"] + + # -- Audience validation ---------------------------------------------------- + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_audience_passed_to_jwt_decode(self, adapter: JwtAdapter) -> None: + # Arrange + claims = _valid_claims() + + # Act + with patch("src.infrastructure.auth.jwt_adapter.jwt.decode", return_value=claims) as mock_decode: + await adapter.decode_token("some-token") + + # Assert + _, kwargs = mock_decode.call_args + assert kwargs["audience"] == AUDIENCE + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_invalid_audience_returns_none(self, adapter: JwtAdapter) -> None: + # Act + with patch( + "src.infrastructure.auth.jwt_adapter.jwt.decode", + side_effect=jwt.InvalidAudienceError("Audience mismatch"), + ): + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + # -- Expiry / algorithm / malformed claims --------------------------------- + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_expired_token_returns_none(self, adapter: JwtAdapter) -> None: + # Act + with patch( + "src.infrastructure.auth.jwt_adapter.jwt.decode", + side_effect=jwt.ExpiredSignatureError("Token expired"), + ): + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_invalid_algorithm_returns_none(self, adapter: JwtAdapter) -> None: + # Act + with patch( + "src.infrastructure.auth.jwt_adapter.jwt.decode", + side_effect=jwt.InvalidAlgorithmError("Algorithm not supported"), + ): + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_malformed_claims_returns_none(self, adapter: JwtAdapter) -> None: + # Arrange — payload missing required ``sub`` so model_validate raises ValueError + bad_payload = {"email": "no-sub@example.com"} + + # Act + with patch("src.infrastructure.auth.jwt_adapter.jwt.decode", return_value=bad_payload): + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + # -- JWKS fetch HTTP errors ------------------------------------------------- + + async def test_jwks_http_error_returns_none(self, adapter: JwtAdapter) -> None: + # Arrange + adapter._jwks_http_client = AsyncMock() + adapter._jwks_http_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + + # Act + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + async def test_jwks_no_matching_kid_returns_none(self, adapter: JwtAdapter) -> None: + """Empty JWKS keys list → PyJWKClientError → returns None.""" + # Arrange + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {"keys": []} + adapter._jwks_http_client = AsyncMock() + adapter._jwks_http_client.get = AsyncMock(return_value=mock_response) + + # Act + with patch( + "src.infrastructure.auth.jwt_adapter.jwt.get_unverified_header", + return_value={"kid": "missing-kid"}, + ): + result = await adapter.decode_token("some-token") + + # Assert + assert result is None + + # -- Cache behaviour -------------------------------------------------------- + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_jwks_cached_on_second_decode_call(self, adapter: JwtAdapter) -> None: + # Arrange + claims = _valid_claims() + + # Act + with patch("src.infrastructure.auth.jwt_adapter.jwt.decode", return_value=claims): + await adapter.decode_token("some-token") + await adapter.decode_token("some-token") + + # Assert — JWKS fetched only once; second call uses the in-memory TTLCache + assert adapter._jwks_http_client.get.await_count == 1 + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_cache_reset_between_decodes_refetches(self, adapter: JwtAdapter) -> None: + """Clearing the cache forces a refetch on the next decode.""" + # Arrange + claims = _valid_claims() + + # Act + with patch("src.infrastructure.auth.jwt_adapter.jwt.decode", return_value=claims): + await adapter.decode_token("some-token") + adapter._jwks_cache.clear() # type: ignore[attr-defined] + await adapter.decode_token("some-token") + + # Assert — two fetches because the cache was cleared between calls + assert adapter._jwks_http_client.get.await_count == 2 + + # -- PII / logging ---------------------------------------------------------- + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_decode_token_does_not_print(self, adapter: JwtAdapter) -> None: + # Arrange + claims = _valid_claims() + + # Act & Assert + with ( + patch("src.infrastructure.auth.jwt_adapter.jwt.decode", return_value=claims), + patch("builtins.print") as mock_print, + ): + await adapter.decode_token("some-token") + + mock_print.assert_not_called() + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_decode_token_logs_failure_without_pii( + self, adapter: JwtAdapter, caplog: pytest.LogCaptureFixture + ) -> None: + """On decode failure the log must not leak the email/sub from the payload.""" + # Arrange + claims = _valid_claims() + + # Act + with ( + patch( + "src.infrastructure.auth.jwt_adapter.jwt.decode", + side_effect=jwt.ExpiredSignatureError("Token expired"), + ), + caplog.at_level(logging.DEBUG, logger="src.infrastructure.auth.jwt_adapter"), + ): + await adapter.decode_token("some-token") + + # Assert — no PII (email, sub value, full payload) in any log record + all_messages = " ".join(r.getMessage() for r in caplog.records) + assert claims["email"] not in all_messages, "Email PII leaked in logs" + assert claims["sub"] not in all_messages, "Sub PII leaked in logs" + assert "Alice Martin" not in all_messages, "Name PII leaked in logs" + # A failure log was emitted + assert any(r.levelno >= logging.WARNING for r in caplog.records) + + @pytest.mark.usefixtures("_stub_signing_key") + async def test_decode_failure_logs_decode_failed_message( + self, adapter: JwtAdapter, caplog: pytest.LogCaptureFixture + ) -> None: + """A failed decode should log the centralized ``AUTH_JWT_DECODE_FAILED`` message.""" + # Act + with ( + patch( + "src.infrastructure.auth.jwt_adapter.jwt.decode", + side_effect=jwt.ExpiredSignatureError("Token expired"), + ), + caplog.at_level(logging.WARNING, logger="src.infrastructure.auth.jwt_adapter"), + ): + await adapter.decode_token("some-token") + + # Assert + assert any(LogMessage.AUTH_JWT_DECODE_FAILED in r.getMessage() for r in caplog.records) + + async def test_jwks_fetch_failure_logs_jwks_fetch_failed_message( + self, adapter: JwtAdapter, caplog: pytest.LogCaptureFixture + ) -> None: + """A JWKS HTTP error should log ``AUTH_JWKS_FETCH_FAILED``.""" + # Arrange + adapter._jwks_http_client = AsyncMock() + adapter._jwks_http_client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) + + # Act + with caplog.at_level(logging.ERROR, logger="src.infrastructure.auth.jwt_adapter"): + await adapter.decode_token("some-token") + + # Assert + assert any(LogMessage.AUTH_JWKS_FETCH_FAILED in r.getMessage() for r in caplog.records) + + # -- PyJWKClientError path -------------------------------------------------- + + async def test_pyjwk_client_error_returns_none(self, adapter: JwtAdapter) -> None: + """When ``PyJWK.from_dict`` raises ``PyJWKClientError`` the adapter returns None.""" + # Arrange + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = _jwks_dict() + adapter._jwks_http_client = AsyncMock() + adapter._jwks_http_client.get = AsyncMock(return_value=mock_response) + + # Act + with ( + patch( + "src.infrastructure.auth.jwt_adapter.jwt.get_unverified_header", + return_value={"kid": "test-kid"}, + ), + patch( + "src.infrastructure.auth.jwt_adapter.PyJWK.from_dict", + side_effect=PyJWKClientError("bad key"), + ), + ): + result = await adapter.decode_token("some-token") + + # Assert + assert result is None diff --git a/tests/unit/test_mcp_adapter_user_credentials.py b/tests/unit/test_mcp_adapter_user_credentials.py new file mode 100644 index 0000000..a8b7be0 --- /dev/null +++ b/tests/unit/test_mcp_adapter_user_credentials.py @@ -0,0 +1,179 @@ +"""Tests for MCP credential propagation in :class:`LangchainMcpToolLoader`. + +When an agent calls a remote MCP server (e.g. raganything), the outgoing +request should carry the CURRENT USER's credential instead of a static env-var +key. The loader resolves ``${USER_JWT}`` and ``${USER_API_KEY}`` placeholders +from the RLS contextvars. Empty resolved header values are DROPPED so a +JWT-authed request doesn't send an empty ``X-API-Key`` (and vice versa). + +``MultiServerMCPClient`` is patched (external boundary) so no real MCP server +is contacted. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from src.domain.entities.mcp_server_config import McpServerConfig, McpTransportType +from src.infrastructure.database.rls_context import current_auth_method, current_credential +from src.infrastructure.mcp.adapter import LangchainMcpToolLoader + + +def _config(headers: dict[str, str]) -> McpServerConfig: + return McpServerConfig( + name="raganything", + transport=McpTransportType.HTTP, + url="http://raganything-api:8000/classical/mcp", + headers=headers, + ) + + +class TestMcpUserCredentialPropagation: + """``${USER_JWT}`` / ``${USER_API_KEY}`` resolved + empty headers dropped.""" + + async def test_jwt_method_sends_bearer_and_drops_empty_api_key(self) -> None: + # Arrange + config = _config( + { + "Authorization": "Bearer ${USER_JWT}", + "X-API-Key": "${USER_API_KEY}", + } + ) + mock_client = AsyncMock() + mock_client.get_tools = AsyncMock(return_value=[]) + + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("tok123") + try: + # Act + with patch( + "src.infrastructure.mcp.adapter.MultiServerMCPClient", + return_value=mock_client, + ) as mock_cls: + loader = LangchainMcpToolLoader() + await loader.load_tools([config]) + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert — Authorization resolved, X-API-Key dropped (empty) + call_args = mock_cls.call_args[0][0] + headers = call_args["raganything"]["headers"] + assert headers == {"Authorization": "Bearer tok123"} + + async def test_api_key_method_sends_x_api_key_and_drops_empty_bearer(self) -> None: + # Arrange + config = _config( + { + "Authorization": "Bearer ${USER_JWT}", + "X-API-Key": "${USER_API_KEY}", + } + ) + mock_client = AsyncMock() + mock_client.get_tools = AsyncMock(return_value=[]) + + tok_m = current_auth_method.set("api_key") + tok_c = current_credential.set("cpk_xyz") + try: + # Act + with patch( + "src.infrastructure.mcp.adapter.MultiServerMCPClient", + return_value=mock_client, + ) as mock_cls: + loader = LangchainMcpToolLoader() + await loader.load_tools([config]) + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert — X-API-Key resolved, Authorization dropped (empty Bearer) + call_args = mock_cls.call_args[0][0] + headers = call_args["raganything"]["headers"] + assert headers == {"X-API-Key": "cpk_xyz"} + + async def test_no_contextvar_drops_both_empty_headers(self) -> None: + # Arrange + config = _config( + { + "Authorization": "Bearer ${USER_JWT}", + "X-API-Key": "${USER_API_KEY}", + } + ) + mock_client = AsyncMock() + mock_client.get_tools = AsyncMock(return_value=[]) + + assert current_auth_method.get() is None + + # Act + with patch( + "src.infrastructure.mcp.adapter.MultiServerMCPClient", + return_value=mock_client, + ) as mock_cls: + loader = LangchainMcpToolLoader() + await loader.load_tools([config]) + + # Assert — both headers dropped (empty) + call_args = mock_cls.call_args[0][0] + headers = call_args["raganything"]["headers"] + assert headers == {} + + async def test_non_empty_static_header_preserved(self) -> None: + # Arrange + config = _config( + { + "Authorization": "Bearer ${USER_JWT}", + "X-Custom-Header": "static-value", + } + ) + mock_client = AsyncMock() + mock_client.get_tools = AsyncMock(return_value=[]) + + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("tok") + try: + # Act + with patch( + "src.infrastructure.mcp.adapter.MultiServerMCPClient", + return_value=mock_client, + ) as mock_cls: + loader = LangchainMcpToolLoader() + await loader.load_tools([config]) + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert — static header preserved, Authorization resolved + call_args = mock_cls.call_args[0][0] + headers = call_args["raganything"]["headers"] + assert headers == {"Authorization": "Bearer tok", "X-Custom-Header": "static-value"} + + async def test_os_env_var_still_resolved_alongside_user_credential(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setenv("MCP_TIMEOUT", "30") + config = _config( + { + "Authorization": "Bearer ${USER_JWT}", + "X-Timeout": "${MCP_TIMEOUT}", + } + ) + mock_client = AsyncMock() + mock_client.get_tools = AsyncMock(return_value=[]) + + tok_m = current_auth_method.set("jwt") + tok_c = current_credential.set("jwt-tok") + try: + # Act + with patch( + "src.infrastructure.mcp.adapter.MultiServerMCPClient", + return_value=mock_client, + ) as mock_cls: + loader = LangchainMcpToolLoader() + await loader.load_tools([config]) + finally: + current_auth_method.reset(tok_m) + current_credential.reset(tok_c) + + # Assert + call_args = mock_cls.call_args[0][0] + headers = call_args["raganything"]["headers"] + assert headers == {"Authorization": "Bearer jwt-tok", "X-Timeout": "30"} diff --git a/tests/unit/test_namespace.py b/tests/unit/test_namespace.py new file mode 100644 index 0000000..7dfaacc --- /dev/null +++ b/tests/unit/test_namespace.py @@ -0,0 +1,81 @@ +"""Tests for the ``user_namespaced`` helper. + +The helper builds a LangGraph Store namespace tuple prefixed by the current +authenticated user id (read from the ``current_user_id`` contextvar). When the +contextvar is ``None`` (no auth context, e.g. existing tests), the user prefix +is dropped so the namespace falls back to the legacy global tuple — keeping +all pre-existing tests green. +""" + +from src.infrastructure.database.rls_context import current_user_id +from src.infrastructure.deepagent.namespace import user_namespaced + + +class TestUserNamespaced: + """``user_namespaced`` resolves the namespace from the RLS contextvar.""" + + def test_returns_user_prefixed_tuple_when_contextvar_set(self) -> None: + # Arrange + token = current_user_id.set("u1") + try: + # Act + ns = user_namespaced("filesystem") + finally: + current_user_id.reset(token) + + # Assert + assert ns == ("u1", "filesystem") + + def test_returns_legacy_tuple_when_contextvar_none(self) -> None: + # Arrange — ensure default + assert current_user_id.get() is None + + # Act + ns = user_namespaced("filesystem") + + # Assert + assert ns == ("filesystem",) + + def test_supports_multiple_suffix_segments(self) -> None: + # Arrange + token = current_user_id.set("uA") + try: + # Act + ns = user_namespaced("agents", "agent1") + finally: + current_user_id.reset(token) + + # Assert + assert ns == ("uA", "agents", "agent1") + + def test_multiple_suffix_legacy_when_none(self) -> None: + # Arrange + assert current_user_id.get() is None + + # Act + ns = user_namespaced("agents", "agent1") + + # Assert + assert ns == ("agents", "agent1") + + def test_empty_suffix_returns_just_user_id_when_set(self) -> None: + # Arrange + token = current_user_id.set("uX") + try: + # Act + ns = user_namespaced() + finally: + current_user_id.reset(token) + + # Assert + assert ns == ("uX",) + + def test_empty_suffix_returns_empty_tuple_when_none(self) -> None: + # Arrange + assert current_user_id.get() is None + + # Act + ns = user_namespaced() + + # Assert + assert ns == () diff --git a/tests/unit/test_postgres_repository.py b/tests/unit/test_postgres_repository.py index f586f6f..ae7218f 100644 --- a/tests/unit/test_postgres_repository.py +++ b/tests/unit/test_postgres_repository.py @@ -169,8 +169,6 @@ async def test_save_persists_description(self, repository, db_session): await repository.save(metadata) # Assert — read the raw ORM row to confirm the column was written - result = await db_session.execute( - select(AgentConfigModel).where(AgentConfigModel.name == "desc-agent") - ) + result = await db_session.execute(select(AgentConfigModel).where(AgentConfigModel.name == "desc-agent")) model = result.scalar_one() assert model.description == "Persisted description" diff --git a/tests/unit/test_prepare_agent_namespace_user_isolation.py b/tests/unit/test_prepare_agent_namespace_user_isolation.py new file mode 100644 index 0000000..06da4d4 --- /dev/null +++ b/tests/unit/test_prepare_agent_namespace_user_isolation.py @@ -0,0 +1,117 @@ +"""Tests for per-user namespace isolation in ``_prepare_agent_namespace``. + +The factory copies selected skills/memories from the user-scoped source +namespace (``(user_id, "filesystem")``) into the agent's namespace +(``/agents/{name}/...`` within the same user-scoped namespace). When user A +runs an agent, only user A's skills are copied — user B's are invisible. + +Uses a real :class:`InMemoryStore` (no DB required). +""" + +import pytest +from langgraph.store.memory import InMemoryStore + +from src.infrastructure.database.rls_context import current_user_id +from src.infrastructure.deepagent.factory import _prepare_agent_namespace + + +@pytest.fixture +def store() -> InMemoryStore: + """Provide a fresh real InMemoryStore per test.""" + return InMemoryStore() + + +async def _put(store: InMemoryStore, ns: tuple[str, ...], key: str, content: str) -> None: + """Helper: write a file into the store under the given namespace.""" + await store.aput(ns, key, {"content": content, "encoding": "utf-8"}) + + +class TestPrepareAgentNamespaceUserIsolation: + """Skills/memories are copied from the current user's namespace only.""" + + async def test_user_a_run_copies_only_user_a_skills(self, store: InMemoryStore) -> None: + # Arrange — seed a skill under uA and a different skill under uB + await _put(store, ("uA", "filesystem"), "/skills/foo/SKILL.md", "# A foo skill") + await _put(store, ("uB", "filesystem"), "/skills/foo/SKILL.md", "# B foo skill") + + # Act — run _prepare_agent_namespace as uA, selecting /skills/foo/ + tok_a = current_user_id.set("uA") + try: + skills_dir, _mem = await _prepare_agent_namespace(store, "agent1", ["/skills/foo/"], []) + finally: + current_user_id.reset(tok_a) + + # Assert — the copied SKILL.md under uA's agent namespace is uA's content + item = await store.aget(("uA", "filesystem"), f"{skills_dir}foo/SKILL.md") + assert item is not None + assert item.value["content"] == "# A foo skill" + + # And uB's namespace does NOT contain the agent copy + item_b = await store.aget(("uB", "filesystem"), f"{skills_dir}foo/SKILL.md") + assert item_b is None + + async def test_user_b_run_copies_only_user_b_skills(self, store: InMemoryStore) -> None: + # Arrange — seed skills under uA and uB + await _put(store, ("uA", "filesystem"), "/skills/foo/SKILL.md", "# A") + await _put(store, ("uB", "filesystem"), "/skills/foo/SKILL.md", "# B") + + # Act — run as uB + tok_b = current_user_id.set("uB") + try: + skills_dir, _mem = await _prepare_agent_namespace(store, "agent1", ["/skills/foo/"], []) + finally: + current_user_id.reset(tok_b) + + # Assert — uB's agent namespace contains uB's content + item = await store.aget(("uB", "filesystem"), f"{skills_dir}foo/SKILL.md") + assert item is not None + assert item.value["content"] == "# B" + + async def test_memories_scoped_per_user(self, store: InMemoryStore) -> None: + # Arrange — seed memory under uA and uB + await _put(store, ("uA", "filesystem"), "/memories/AGENTS.md", "# A agents") + await _put(store, ("uB", "filesystem"), "/memories/AGENTS.md", "# B agents") + + # Act — run as uA + tok_a = current_user_id.set("uA") + try: + _skills_dir, mem_paths = await _prepare_agent_namespace(store, "agent1", [], ["/memories/AGENTS.md"]) + finally: + current_user_id.reset(tok_a) + + # Assert — uA's agent memory is uA's content + item = await store.aget(("uA", "filesystem"), mem_paths[0]) + assert item is not None + assert item.value["content"] == "# A agents" + + async def test_legacy_no_contextvar_uses_global_namespace(self, store: InMemoryStore) -> None: + # Arrange — seed a skill under the legacy ("filesystem",) namespace + await _put(store, ("filesystem",), "/skills/foo/SKILL.md", "# legacy") + + # Act — no contextvar + assert current_user_id.get() is None + skills_dir, _mem = await _prepare_agent_namespace(store, "agent1", ["/skills/foo/"], []) + + # Assert — the copy lives under ("filesystem",) + item = await store.aget(("filesystem",), f"{skills_dir}foo/SKILL.md") + assert item is not None + assert item.value["content"] == "# legacy" + + async def test_cleanup_only_affects_current_user_namespace(self, store: InMemoryStore) -> None: + # Arrange — uA has a stale agent skill; uB has its own + await _put(store, ("uA", "filesystem"), "/agents/agent1/skills/old/SKILL.md", "# old A") + await _put(store, ("uB", "filesystem"), "/agents/agent1/skills/old/SKILL.md", "# old B") + + # Act — run as uA selecting a DIFFERENT skill (triggers cleanup of "old") + tok_a = current_user_id.set("uA") + try: + await _prepare_agent_namespace(store, "agent1", ["/skills/new/"], []) + finally: + current_user_id.reset(tok_a) + + # Assert — uA's "old" is deleted, uB's "old" is preserved + item_a = await store.aget(("uA", "filesystem"), "/agents/agent1/skills/old/SKILL.md") + assert item_a is None + item_b = await store.aget(("uB", "filesystem"), "/agents/agent1/skills/old/SKILL.md") + assert item_b is not None + assert item_b.value["content"] == "# old B" diff --git a/tests/unit/test_rls_context.py b/tests/unit/test_rls_context.py new file mode 100644 index 0000000..c0bde55 --- /dev/null +++ b/tests/unit/test_rls_context.py @@ -0,0 +1,88 @@ +"""Tests for the RLS contextvars module. + +The module exposes ``current_user_id``, ``current_credential`` and +``bypass_rls`` contextvars plus a ``system_rls_context`` async context manager +that temporarily enables RLS bypass for system/migration queries. +""" + +import pytest + +from src.infrastructure.database.rls_context import ( + bypass_rls, + current_credential, + current_user_id, + system_rls_context, +) + + +class TestRlsContextDefaults: + """Tests for contextvar default values.""" + + def test_current_user_id_defaults_to_none(self) -> None: + # Act & Assert + assert current_user_id.get() is None + + def test_current_credential_defaults_to_none(self) -> None: + # Act & Assert + assert current_credential.get() is None + + def test_bypass_rls_defaults_to_false(self) -> None: + # Act & Assert + assert bypass_rls.get() is False + + +class TestRlsContextSetGet: + """Tests for setting/getting contextvars within a test.""" + + def test_current_user_id_set_then_get_returns_value(self) -> None: + # Arrange + token = current_user_id.set("user-123") + try: + # Act & Assert + assert current_user_id.get() == "user-123" + finally: + current_user_id.reset(token) + assert current_user_id.get() is None + + def test_current_credential_set_then_get_returns_value(self) -> None: + # Arrange + token = current_credential.set("raw-jwt") + try: + # Act & Assert + assert current_credential.get() == "raw-jwt" + finally: + current_credential.reset(token) + assert current_credential.get() is None + + +class TestSystemRlsContext: + """Tests for the ``system_rls_context`` async context manager.""" + + async def test_system_rls_context_sets_bypass_true_inside(self) -> None: + # Act & Assert + async with system_rls_context(): + assert bypass_rls.get() is True + + async def test_system_rls_context_resets_bypass_after_exit(self) -> None: + # Arrange + assert bypass_rls.get() is False + + # Act + async with system_rls_context(): + assert bypass_rls.get() is True + + # Assert + assert bypass_rls.get() is False + + async def test_system_rls_context_resets_even_on_exception(self) -> None: + # Arrange + assert bypass_rls.get() is False + + # Act & Assert + with pytest.raises(RuntimeError, match="boom"): + async with system_rls_context(): + assert bypass_rls.get() is True + raise RuntimeError("boom") + + # Assert — bypass reset after the exception propagated + assert bypass_rls.get() is False diff --git a/tests/unit/test_rls_listener.py b/tests/unit/test_rls_listener.py new file mode 100644 index 0000000..d853241 --- /dev/null +++ b/tests/unit/test_rls_listener.py @@ -0,0 +1,180 @@ +"""Tests for the SQLAlchemy ``before_cursor_execute`` RLS event listener. + +The listener is registered on the engine's sync engine in +:func:`src.dependencies.init_persistence` and on the test ``db_engine`` fixture +via :func:`src.infrastructure.database.rls_listener.register_rls_listener`. + +Behaviour: + +* On **SQLite** (tests) the listener MUST be a no-op (``SET LOCAL`` is a + Postgres-only statement) — it must not raise. +* On **PostgreSQL** it emits ``SELECT set_config('app.user_id', $1, true)`` + when ``current_user_id`` is set, and ``SET LOCAL row_security = off`` when + ``bypass_rls`` is True. +* Calling ``cursor.execute`` on the raw DBAPI cursor does NOT re-trigger the + listener (no infinite recursion). + +The SQLite-path tests run a real query through a real in-memory SQLite engine +with the listener registered. The Postgres-path tests call the listener +function directly with a mock cursor + mock conn (whose ``dialect.name`` is +``"postgresql"``) so we can capture the emitted SQL without running it on +SQLite (whose cursor.execute is read-only and would reject ``SET LOCAL``). +""" + +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from src.infrastructure.database.rls_context import bypass_rls, current_user_id +from src.infrastructure.database.rls_listener import ( + _set_rls_guc_before_execute, + register_rls_listener, +) + + +@pytest.fixture +async def sqlite_engine(): + """Real in-memory SQLite async engine with the RLS listener registered.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + register_rls_listener(engine) + try: + yield engine + finally: + await engine.dispose() + + +def _make_postgres_conn_and_cursor() -> tuple[MagicMock, MagicMock]: + """Build a mock (conn, cursor) pair simulating a PostgreSQL connection. + + ``conn.dialect.name`` is ``"postgresql"`` and ``cursor.execute`` is a + ``MagicMock`` so we can assert on the emitted statement text. + """ + conn = MagicMock() + conn.dialect.name = "postgresql" + cursor = MagicMock() + return conn, cursor + + +class TestRlsListenerSqlite: + """Listener must be a no-op on SQLite (no SET LOCAL emitted).""" + + async def test_listener_does_not_raise_on_sqlite_when_user_id_set(self, sqlite_engine): + # Arrange — set the contextvar + token = current_user_id.set("u1") + try: + # Act — run a real query through the engine + async with sqlite_engine.connect() as conn: + result = await conn.execute(text("SELECT 1")) + # Assert — query succeeds (listener did not raise) + assert result.scalar() == 1 + finally: + current_user_id.reset(token) + + async def test_listener_does_not_raise_on_sqlite_when_bypass_set(self, sqlite_engine): + # Arrange + token = bypass_rls.set(True) + try: + async with sqlite_engine.connect() as conn: + result = await conn.execute(text("SELECT 1")) + assert result.scalar() == 1 + finally: + bypass_rls.reset(token) + + async def test_listener_noop_when_no_contextvar_set(self, sqlite_engine): + # Arrange — no contextvar set (default None / False) + # Act + async with sqlite_engine.connect() as conn: + result = await conn.execute(text("SELECT 1")) + # Assert + assert result.scalar() == 1 + + +class TestRlsListenerPostgresEmulation: + """Listener emits the right SET statements when dialect is postgresql. + + We call ``_set_rls_guc_before_execute`` directly with a mock (conn, cursor) + pair whose ``conn.dialect.name`` is ``"postgresql"`` so we can capture the + emitted SQL on the mock cursor without running it on SQLite. + """ + + async def test_emits_set_user_id_when_current_user_id_set(self): + # Arrange + conn, cursor = _make_postgres_conn_and_cursor() + token = current_user_id.set("u1") + try: + # Act + _set_rls_guc_before_execute(conn, cursor, "SELECT 1", None, None, False) + finally: + current_user_id.reset(token) + + # Assert — cursor.execute was called with set_config('app.user_id', ...) + assert cursor.execute.called + first_call_args = cursor.execute.call_args_list[0] + stmt = first_call_args.args[0] + params = first_call_args.args[1] if len(first_call_args.args) > 1 else None + assert "app.user_id" in stmt + assert params == ("u1",) + + async def test_emits_row_security_off_when_bypass_rls_true(self): + # Arrange + conn, cursor = _make_postgres_conn_and_cursor() + token = bypass_rls.set(True) + try: + # Act + _set_rls_guc_before_execute(conn, cursor, "SELECT 1", None, None, False) + finally: + bypass_rls.reset(token) + + # Assert + assert cursor.execute.called + stmt = cursor.execute.call_args_list[0].args[0] + assert "row_security" in stmt + assert "off" in stmt + + async def test_no_set_emitted_when_no_contextvar_and_not_bypass(self): + # Arrange — defaults (None / False) + conn, cursor = _make_postgres_conn_and_cursor() + assert current_user_id.get() is None + assert bypass_rls.get() is False + # Act + _set_rls_guc_before_execute(conn, cursor, "SELECT 1", None, None, False) + # Assert — cursor.execute was NOT called + assert not cursor.execute.called + + async def test_bypass_takes_precedence_over_user_id(self): + # Arrange — both set + conn, cursor = _make_postgres_conn_and_cursor() + tok_u = current_user_id.set("u1") + tok_b = bypass_rls.set(True) + try: + # Act + _set_rls_guc_before_execute(conn, cursor, "SELECT 1", None, None, False) + finally: + current_user_id.reset(tok_u) + bypass_rls.reset(tok_b) + + # Assert — only row_security=off was emitted (bypass returns early) + assert cursor.execute.call_count == 1 + stmt = cursor.execute.call_args_list[0].args[0] + assert "row_security" in stmt + + +class TestRlsListenerIdempotentRegistration: + """register_rls_listener is idempotent and does not stack listeners.""" + + def test_register_twice_does_not_raise(self): + # Use a MagicMock engine to avoid building a real one + mock_engine = MagicMock() + mock_engine.sync_engine = MagicMock() + # Act + Assert — second call must not raise + register_rls_listener(mock_engine) + register_rls_listener(mock_engine) + + def test_register_sets_sentinel_flag(self): + mock_engine = MagicMock() + mock_engine.sync_engine = MagicMock() + register_rls_listener(mock_engine) + # Assert — the sentinel flag is set on the sync engine + assert getattr(mock_engine.sync_engine, "_composable_agents_rls_listener_registered", False) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 78f15f0..ea19a58 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -196,6 +196,25 @@ def mock_config_repository(): ) for name in sorted(AGENTS) ] + + # ``get`` mirrors the RLS-filtered Postgres repository: returns metadata + # for known agents, raises AgentNotFoundError for unknown ones. This is + # what GetAgentConfigUseCase now relies on for the ownership check before + # touching the shared MinIO bucket. + async def _get(name): + from src.domain.errors.agent import AgentNotFoundError + + if name not in AGENTS: + raise AgentNotFoundError(f"Agent config not found: {name}") + return AgentConfigMetadata( + name=name, + model="test-model", + minio_path=f"{name}.yaml", + created_at=now, + updated_at=now, + ) + + repo.get.side_effect = _get return repo @@ -226,7 +245,7 @@ def _delete_thread(): return DeleteThreadUseCase(thread_repo) def _get_agent_config(): - return GetAgentConfigUseCase(yaml_loader, mock_config_store) + return GetAgentConfigUseCase(yaml_loader, mock_config_store, mock_config_repository) def _list_agent_configs(): return ListAgentConfigsUseCase(mock_config_repository) @@ -240,9 +259,16 @@ def _update_agent_config(): def _delete_agent_config(): return DeleteAgentConfigUseCase(mock_config_store, mock_config_repository, stub_registry) - # Bypass API key security for route tests — security is covered by - # tests/unit/test_security.py with a dedicated minimal app. - app.dependency_overrides[security.verify_api_key] = lambda: "" + # Bypass dual-auth security for route tests — security is covered by + # tests/unit/test_security.py and tests/unit/test_verify_credentials_wiring.py + # with dedicated apps. The protected router now depends on + # ``verify_credentials`` (dual JWT/API-key); we override it to a no-op + # returning a fixed AuthContext so the route handlers run without auth. + from src.domain.entities.auth.auth_context import AuthContext + + app.dependency_overrides[security.verify_credentials] = lambda: AuthContext( + user_id="test-user", method="api_key", raw_credential="" + ) app.dependency_overrides[get_send_message_use_case] = _send_message app.dependency_overrides[get_stream_message_use_case] = _stream_message diff --git a/tests/unit/test_store_file_user_isolation.py b/tests/unit/test_store_file_user_isolation.py new file mode 100644 index 0000000..f469670 --- /dev/null +++ b/tests/unit/test_store_file_user_isolation.py @@ -0,0 +1,186 @@ +"""Tests for per-user namespace isolation in :class:`LangGraphStoreFileRepository`. + +The repository is constructed with a ``namespace_provider`` callable returning +the current user-scoped namespace (``user_namespaced("filesystem")``). When +``current_user_id`` is set, files written by user A are invisible to user B. +When the contextvar is ``None`` (legacy / tests), the namespace falls back to +``("filesystem",)`` so both users' data is visible (existing behaviour). + +Uses a real :class:`InMemoryStore` (no DB required). +""" + +import pytest +from langgraph.store.memory import InMemoryStore + +from src.infrastructure.database.rls_context import current_user_id +from src.infrastructure.deepagent.namespace import user_namespaced +from src.infrastructure.store_file.adapter import LangGraphStoreFileRepository + + +@pytest.fixture +def store() -> InMemoryStore: + """Provide a fresh real InMemoryStore per test.""" + return InMemoryStore() + + +@pytest.fixture +def repo(store: InMemoryStore) -> LangGraphStoreFileRepository: + """Repository wired with a per-user namespace provider.""" + return LangGraphStoreFileRepository(store=store, namespace_provider=lambda: user_namespaced("filesystem")) + + +class TestStoreFileUserIsolation: + """Per-user isolation driven by ``current_user_id``.""" + + async def test_user_a_files_invisible_to_user_b_list( + self, store: InMemoryStore, repo: LangGraphStoreFileRepository + ) -> None: + # Arrange — write a file under uA + tok_a = current_user_id.set("uA") + try: + await repo.put_file("/skills/x/SKILL.md", "# A skill") + finally: + current_user_id.reset(tok_a) + + # Act — list under uB + tok_b = current_user_id.set("uB") + try: + files = await repo.list_files("/skills/") + finally: + current_user_id.reset(tok_b) + + # Assert — uB sees nothing + assert files == [] + + async def test_user_a_files_invisible_to_user_b_get( + self, store: InMemoryStore, repo: LangGraphStoreFileRepository + ) -> None: + # Arrange — write a file under uA + tok_a = current_user_id.set("uA") + try: + await repo.put_file("/skills/x/SKILL.md", "# A skill") + finally: + current_user_id.reset(tok_a) + + # Act — get under uB + tok_b = current_user_id.set("uB") + try: + content = await repo.get_file("/skills/x/SKILL.md") + finally: + current_user_id.reset(tok_b) + + # Assert — uB cannot read uA's file + assert content is None + + async def test_user_b_can_write_and_read_own_files( + self, store: InMemoryStore, repo: LangGraphStoreFileRepository + ) -> None: + # Arrange — uA writes a file + tok_a = current_user_id.set("uA") + try: + await repo.put_file("/skills/a/SKILL.md", "# A") + finally: + current_user_id.reset(tok_a) + + # Act — uB writes its own file then lists + tok_b = current_user_id.set("uB") + try: + await repo.put_file("/skills/b/SKILL.md", "# B") + files = await repo.list_files("/skills/") + content_b = await repo.get_file("/skills/b/SKILL.md") + finally: + current_user_id.reset(tok_b) + + # Assert — uB only sees its own file + assert files == ["/skills/b/SKILL.md"] + assert content_b == "# B" + + async def test_delete_under_uB_does_not_remove_uA_file( + self, store: InMemoryStore, repo: LangGraphStoreFileRepository + ) -> None: + # Arrange — uA writes + tok_a = current_user_id.set("uA") + try: + await repo.put_file("/skills/shared/SKILL.md", "# A") + finally: + current_user_id.reset(tok_a) + + # Act — uB deletes the same path (no-op, different namespace) + tok_b = current_user_id.set("uB") + try: + await repo.delete_file("/skills/shared/SKILL.md") + finally: + current_user_id.reset(tok_b) + + # Assert — uA still has its file + tok_a2 = current_user_id.set("uA") + try: + content = await repo.get_file("/skills/shared/SKILL.md") + finally: + current_user_id.reset(tok_a2) + assert content == "# A" + + async def test_legacy_no_contextvar_both_visible( + self, store: InMemoryStore, repo: LangGraphStoreFileRepository + ) -> None: + # Arrange — write "as uA" then "as uB" then unset contextvar + tok_a = current_user_id.set("uA") + try: + await repo.put_file("/skills/a/SKILL.md", "# A") + finally: + current_user_id.reset(tok_a) + + tok_b = current_user_id.set("uB") + try: + await repo.put_file("/skills/b/SKILL.md", "# B") + finally: + current_user_id.reset(tok_b) + + # Act — no contextvar (default None) → legacy namespace ("filesystem",) + assert current_user_id.get() is None + files = await repo.list_files("/skills/") + + # Assert — both visible under the legacy global namespace + # (the legacy namespace is distinct from uA/uB namespaces, so it's empty + # unless something was written without a user prefix) + assert files == [] + + async def test_legacy_writes_visible_across_no_contextvar( + self, store: InMemoryStore, repo: LangGraphStoreFileRepository + ) -> None: + # Arrange — write with no contextvar (legacy namespace ("filesystem",)) + assert current_user_id.get() is None + await repo.put_file("/skills/legacy/SKILL.md", "# legacy") + + # Act — read with no contextvar + content = await repo.get_file("/skills/legacy/SKILL.md") + + # Assert + assert content == "# legacy" + + +class TestStoreFileRepositoryBackwardCompat: + """Backward compatibility: no namespace_provider → static default.""" + + async def test_no_provider_uses_default_namespace(self, store: InMemoryStore) -> None: + # Arrange — construct like the existing tests do (no provider) + repo = LangGraphStoreFileRepository(store=store) + + # Act + await repo.put_file("/skills/x/SKILL.md", "# x") + + # Assert — the store received the write under ("filesystem",) + item = await store.aget(("filesystem",), "/skills/x/SKILL.md") + assert item is not None + assert item.value["content"] == "# x" + + async def test_static_namespace_still_supported(self, store: InMemoryStore) -> None: + # Arrange — explicit static namespace (existing tests use this) + repo = LangGraphStoreFileRepository(store=store, namespace=("custom", "ns")) + + # Act + await repo.put_file("/x.md", "c") + + # Assert + item = await store.aget(("custom", "ns"), "/x.md") + assert item is not None diff --git a/tests/unit/test_store_routes.py b/tests/unit/test_store_routes.py index 460ed70..22699a9 100644 --- a/tests/unit/test_store_routes.py +++ b/tests/unit/test_store_routes.py @@ -74,8 +74,18 @@ def _override_dependencies( mock_put_use_case: AsyncMock, mock_delete_use_case: AsyncMock, ): - """Wire mocked use cases via app.dependency_overrides and bypass API key.""" - app.dependency_overrides[security.verify_api_key] = lambda: "" + """Wire mocked use cases via app.dependency_overrides and bypass auth. + + The protected router now depends on ``verify_credentials`` (dual JWT / + API-key) instead of the master-key ``verify_api_key``. We override it to a + fixed AuthContext so the route handlers run without real auth. The auth + behaviour itself is covered by ``test_verify_credentials_wiring.py``. + """ + from src.domain.entities.auth.auth_context import AuthContext + + app.dependency_overrides[security.verify_credentials] = lambda: AuthContext( + user_id="test-user", method="api_key", raw_credential="" + ) app.dependency_overrides[get_list_store_files_use_case] = lambda: mock_list_use_case app.dependency_overrides[get_get_store_file_use_case] = lambda: mock_get_use_case app.dependency_overrides[get_put_store_file_use_case] = lambda: mock_put_use_case diff --git a/tests/unit/test_thread_user_isolation.py b/tests/unit/test_thread_user_isolation.py new file mode 100644 index 0000000..35f410b --- /dev/null +++ b/tests/unit/test_thread_user_isolation.py @@ -0,0 +1,158 @@ +"""Tests for per-user isolation in :class:`PostgresThreadRepository`. + +The repository reads the ``current_user_id`` contextvar and: + +* On **writes** (``create``) — sets the row's ``user_id`` to the contextvar + value (or ``""`` when the contextvar is unset, preserving existing + behaviour). +* On **reads** (``get`` / ``list_all``) — filters by ``user_id == contextvar`` + when the contextvar is set. When the contextvar is ``None`` no filter is + applied (existing behaviour, so the pre-auth-core test suite stays green). +* On **delete** — filters by ``user_id`` when the contextvar is set; deleting + another user's thread raises :class:`ThreadNotFoundError`. + +Uses the shared ``db_engine`` + ``thread_repo`` fixtures (real SQLite). +""" + +import pytest + +from src.domain.entities.thread import Thread +from src.domain.errors.thread import ThreadNotFoundError +from src.infrastructure.database.rls_context import current_user_id + + +class TestThreadUserIsolation: + """Per-user filtering driven by the ``current_user_id`` contextvar.""" + + async def test_create_sets_user_id_from_contextvar(self, thread_repo): + # Arrange + token = current_user_id.set("uA") + try: + # Act + thread = await thread_repo.create("agent-x") + finally: + current_user_id.reset(token) + + # Assert — the persisted row carries user_id="uA" + # Re-read with the same contextvar to confirm the filter lets us see it. + token2 = current_user_id.set("uA") + try: + refetched = await thread_repo.get(thread.id) + finally: + current_user_id.reset(token2) + assert refetched.id == thread.id + assert refetched.user_id == "uA" + + async def test_list_under_uB_excludes_uA_thread(self, thread_repo): + # Arrange — create under uA + tok_a = current_user_id.set("uA") + try: + await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok_a) + + # Act — list under uB + tok_b = current_user_id.set("uB") + try: + threads = await thread_repo.list_all() + finally: + current_user_id.reset(tok_b) + + # Assert — uA's thread is not visible to uB + assert threads == [] + + async def test_get_under_uB_raises_ThreadNotFoundError_for_uA_thread(self, thread_repo): + # Arrange + tok_a = current_user_id.set("uA") + try: + thread = await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok_a) + + # Act / Assert + tok_b = current_user_id.set("uB") + try: + with pytest.raises(ThreadNotFoundError): + await thread_repo.get(thread.id) + finally: + current_user_id.reset(tok_b) + + async def test_list_under_uB_returns_only_uB_threads(self, thread_repo): + # Arrange + tok_a = current_user_id.set("uA") + try: + await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok_a) + + tok_b = current_user_id.set("uB") + try: + thread_b = await thread_repo.create("agent-b") + threads = await thread_repo.list_all() + finally: + current_user_id.reset(tok_b) + + # Assert — only uB's thread is visible + assert len(threads) == 1 + assert threads[0].id == thread_b.id + assert threads[0].user_id == "uB" + + async def test_list_with_no_contextvar_returns_all_threads(self, thread_repo): + # Arrange — no contextvar set; create under uA and uB then unset + tok_a = current_user_id.set("uA") + try: + await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok_a) + + tok_b = current_user_id.set("uB") + try: + await thread_repo.create("agent-b") + finally: + current_user_id.reset(tok_b) + + # Act — no contextvar (default None) + assert current_user_id.get() is None + threads = await thread_repo.list_all() + + # Assert — all threads visible (no filter) + assert len(threads) == 2 + + async def test_delete_under_uB_raises_for_uA_thread(self, thread_repo): + # Arrange + tok_a = current_user_id.set("uA") + try: + thread = await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok_a) + + # Act / Assert — uB cannot delete uA's thread + tok_b = current_user_id.set("uB") + try: + with pytest.raises(ThreadNotFoundError): + await thread_repo.delete(thread.id) + finally: + current_user_id.reset(tok_b) + + # The thread is still visible to uA + tok_a2 = current_user_id.set("uA") + try: + refetched = await thread_repo.get(thread.id) + finally: + current_user_id.reset(tok_a2) + assert refetched.id == thread.id + + async def test_created_thread_has_empty_user_id_when_contextvar_unset(self, thread_repo): + # Arrange — no contextvar + assert current_user_id.get() is None + # Act + thread = await thread_repo.create("agent-x") + # Assert — defaults to "" (matches DB default and existing behaviour) + assert thread.user_id == "" + + async def test_thread_entity_has_user_id_field(self): + # Arrange / Act + t = Thread(agent_name="x") + # Assert + assert hasattr(t, "user_id") + assert t.user_id == "" diff --git a/tests/unit/test_trace_event_user_isolation.py b/tests/unit/test_trace_event_user_isolation.py new file mode 100644 index 0000000..b053a1e --- /dev/null +++ b/tests/unit/test_trace_event_user_isolation.py @@ -0,0 +1,101 @@ +"""Tests for per-user isolation in :class:`PostgresTraceEventRepository`. + +The repository filters trace events by the parent thread's ``user_id`` when +``current_user_id`` is set, so a user can only list events on threads they +own. When the contextvar is ``None`` no filter is applied. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from src.domain.entities.trace_event import TraceEvent, TraceEventType +from src.domain.errors.thread import ThreadNotFoundError +from src.infrastructure.database.rls_context import current_user_id + + +def _make_event( + thread_id: str, turn_id: str, type_: TraceEventType, *, content: str | None = None, sequence: int = 0 +) -> TraceEvent: + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=type_, + content=content, + timestamp=datetime.now(UTC), + sequence=sequence, + ) + + +class TestTraceEventUserIsolation: + """Trace events inherit isolation from their parent thread.""" + + async def test_add_batch_under_uA_persists_events(self, thread_repo, trace_repo): + # Arrange + tok = current_user_id.set("uA") + try: + thread = await thread_repo.create("agent-a") + events = [ + _make_event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="hi", sequence=0), + _make_event(thread.id, "turn-1", TraceEventType.AI_MESSAGE, content="bye", sequence=1), + ] + await trace_repo.add_batch(thread.id, events) + listed = await trace_repo.list_by_thread(thread.id) + finally: + current_user_id.reset(tok) + + # Assert + assert len(listed) == 2 + + async def test_list_by_thread_under_uB_raises_for_uA_thread(self, thread_repo, trace_repo): + # Arrange — uA owns the thread + tok = current_user_id.set("uA") + try: + thread = await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok) + + # Act / Assert — uB cannot see uA's thread → ThreadNotFoundError + tok_b = current_user_id.set("uB") + try: + with pytest.raises(ThreadNotFoundError): + await trace_repo.list_by_thread(thread.id) + finally: + current_user_id.reset(tok_b) + + async def test_add_under_uB_to_uA_thread_raises(self, thread_repo, trace_repo): + # Arrange + tok = current_user_id.set("uA") + try: + thread = await thread_repo.create("agent-a") + finally: + current_user_id.reset(tok) + + # Act / Assert + tok_b = current_user_id.set("uB") + try: + event = _make_event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="hi") + with pytest.raises(ThreadNotFoundError): + await trace_repo.add(thread.id, event) + finally: + current_user_id.reset(tok_b) + + async def test_list_by_thread_no_contextvar_returns_all(self, thread_repo, trace_repo): + # Arrange — create under uA then read with no contextvar + tok = current_user_id.set("uA") + try: + thread = await thread_repo.create("agent-a") + await trace_repo.add( + thread.id, _make_event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="hi") + ) + finally: + current_user_id.reset(tok) + + # Act — no contextvar + assert current_user_id.get() is None + events = await trace_repo.list_by_thread(thread.id) + + # Assert — visible (no filter) + assert len(events) == 1 diff --git a/tests/unit/test_user_llm_settings_repository.py b/tests/unit/test_user_llm_settings_repository.py new file mode 100644 index 0000000..582abf9 --- /dev/null +++ b/tests/unit/test_user_llm_settings_repository.py @@ -0,0 +1,192 @@ +"""Tests for the PostgresUserLlmSettingsRepository against a real in-memory SQLite engine. + +These tests drive the "LLM credentials per user" layer (TDD red phase). They +exercise the real :class:`PostgresUserLlmSettingsRepository` adapter against +the shared in-memory SQLite ``db_engine`` fixture — no mocks on internal +components. The :class:`FernetCrypto` dependency is real (fixed test key). +""" + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities.user_llm_settings import UserLlmSettings +from src.infrastructure.crypto.fernet_crypto import FernetCrypto +from src.infrastructure.database.models.user_llm_setting import UserLlmSettingModel +from src.infrastructure.postgres_user_llm.adapter import PostgresUserLlmSettingsRepository + +_USER_A = "user-aaa" +_USER_B = "user-bbb" + +_PROVIDER = "openai" +_BASE_URL = "https://api.openai.com/v1" +_API_KEY_PLAINTEXT = "sk-test-123456789abcdef" +_TEST_KEY = "Yr5R5-6lRUaxEwZWVysIaFs5POHcLps2OZViwWAscaU=" + + +@pytest.fixture +def crypto() -> FernetCrypto: + return FernetCrypto(key=_TEST_KEY) + + +@pytest.fixture +async def repo(db_engine, crypto) -> PostgresUserLlmSettingsRepository: + """Provide a real PostgresUserLlmSettingsRepository backed by in-memory SQLite.""" + return PostgresUserLlmSettingsRepository(engine=db_engine, crypto=crypto) + + +class TestGetAbsent: + async def test_get_returns_none_when_no_settings(self, repo): + result = await repo.get(_USER_A) + assert result is None + + async def test_get_decrypted_returns_none_when_no_settings(self, repo): + result = await repo.get_decrypted(_USER_A) + assert result is None + + +class TestUpsertInsert: + async def test_upsert_inserts_row_and_returns_settings(self, repo, db_engine, crypto): + # Arrange — produce a real encrypted token + api_key_encrypted = crypto.encrypt(_API_KEY_PLAINTEXT) + + # Act + result = await repo.upsert( + user_id=_USER_A, + provider=_PROVIDER, + base_url=_BASE_URL, + api_key_encrypted=api_key_encrypted, + ) + + # Assert — return shape + assert isinstance(result, UserLlmSettings) + assert result.user_id == _USER_A + assert result.provider == _PROVIDER + assert result.base_url == _BASE_URL + assert result.created_at is not None + assert result.updated_at is not None + + # Assert — row persisted with the encrypted token (not the plaintext) + async with AsyncSession(db_engine, expire_on_commit=False) as session: + row = await session.execute(select(UserLlmSettingModel).where(UserLlmSettingModel.user_id == _USER_A)) + model = row.scalar_one() + assert model.api_key_encrypted == api_key_encrypted + assert model.api_key_encrypted != _API_KEY_PLAINTEXT + + +class TestGetReturnsMasked: + async def test_get_returns_masked_not_full_plaintext(self, repo, crypto): + # Arrange — upsert first with a real encrypted token + await repo.upsert( + user_id=_USER_A, + provider=_PROVIDER, + base_url=_BASE_URL, + api_key_encrypted=crypto.encrypt(_API_KEY_PLAINTEXT), + ) + + # Act + result = await repo.get(_USER_A) + + # Assert + assert result is not None + assert result.api_key_masked is not None + # The masked value must NOT contain the full plaintext + assert result.api_key_masked != _API_KEY_PLAINTEXT + # Masked contains ellipsis + assert "..." in result.api_key_masked + + +class TestUpsertUpdate: + async def test_upsert_twice_updates_row_and_bumps_updated_at(self, repo, db_engine, crypto): + # Arrange — first upsert + first = await repo.upsert( + user_id=_USER_A, + provider=_PROVIDER, + base_url=_BASE_URL, + api_key_encrypted=crypto.encrypt("TOKEN-1"), + ) + + # Force a tiny time delta to be safe across clocks + import asyncio + + await asyncio.sleep(0.01) + + # Act — second upsert updates + second = await repo.upsert( + user_id=_USER_A, + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_key_encrypted=crypto.encrypt("TOKEN-2"), + ) + + # Assert — only one row, content updated, updated_at changed + async with AsyncSession(db_engine, expire_on_commit=False) as session: + rows = ( + (await session.execute(select(UserLlmSettingModel).where(UserLlmSettingModel.user_id == _USER_A))) + .scalars() + .all() + ) + assert len(rows) == 1 + assert rows[0].provider == "openrouter" + assert rows[0].base_url == "https://openrouter.ai/api/v1" + assert rows[0].api_key_encrypted != "TOKEN-2" # encrypted form + assert second.updated_at >= first.updated_at + + +class TestGetDecrypted: + async def test_get_decrypted_returns_base_url_and_plaintext(self, repo, crypto): + # Arrange — upsert with a real encrypted token + await repo.upsert( + user_id=_USER_A, + provider=_PROVIDER, + base_url=_BASE_URL, + api_key_encrypted=crypto.encrypt(_API_KEY_PLAINTEXT), + ) + + # Act + result = await repo.get_decrypted(_USER_A) + + # Assert + assert result is not None + base_url, api_key = result + assert base_url == _BASE_URL + assert api_key == _API_KEY_PLAINTEXT + + +class TestDelete: + async def test_delete_removes_row(self, repo, db_engine, crypto): + # Arrange + await repo.upsert( + user_id=_USER_A, + provider=_PROVIDER, + base_url=_BASE_URL, + api_key_encrypted=crypto.encrypt(_API_KEY_PLAINTEXT), + ) + + # Act + await repo.delete(_USER_A) + + # Assert — row gone + async with AsyncSession(db_engine, expire_on_commit=False) as session: + row = await session.execute(select(UserLlmSettingModel).where(UserLlmSettingModel.user_id == _USER_A)) + assert row.scalar_one_or_none() is None + assert await repo.get(_USER_A) is None + + async def test_delete_when_absent_is_noop(self, repo): + # Act — should not raise + await repo.delete(_USER_A) + + +class TestIsolation: + async def test_settings_for_user_a_invisible_to_user_b(self, repo, crypto): + # Arrange + await repo.upsert( + user_id=_USER_A, + provider=_PROVIDER, + base_url=_BASE_URL, + api_key_encrypted=crypto.encrypt(_API_KEY_PLAINTEXT), + ) + + # Act — user B sees nothing + assert await repo.get(_USER_B) is None + assert await repo.get_decrypted(_USER_B) is None diff --git a/tests/unit/test_user_llm_settings_routes.py b/tests/unit/test_user_llm_settings_routes.py new file mode 100644 index 0000000..5a75695 --- /dev/null +++ b/tests/unit/test_user_llm_settings_routes.py @@ -0,0 +1,186 @@ +"""End-to-end tests for the ``/api/v1/settings/llm`` router. + +Builds a minimal FastAPI app with the ``user_llm_settings`` router and overrides +the ``get_current_user_id`` dependency to return a fixed user id. Uses real +internal components (real repo + real FernetCrypto via the ``db_engine`` +fixture) — no mocks on internal components. +""" + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +from src.application.routes.user_llm_settings import router as llm_settings_router +from src.application.use_cases.user_llm_settings.delete_user_llm_settings import DeleteUserLlmSettingsUseCase +from src.application.use_cases.user_llm_settings.get_user_llm_settings import GetUserLlmSettingsUseCase +from src.application.use_cases.user_llm_settings.upsert_user_llm_settings import ( + UpsertUserLlmSettingsUseCase, +) +from src.dependencies import ( + get_current_user_id, + get_delete_user_llm_settings_use_case, + get_get_user_llm_settings_use_case, + get_upsert_user_llm_settings_use_case, +) +from src.domain.errors.security import AuthenticationError +from src.infrastructure.crypto.fernet_crypto import FernetCrypto +from src.infrastructure.postgres_user_llm.adapter import PostgresUserLlmSettingsRepository + +_USER_ID = "user-test-123" +_TEST_KEY = "Yr5R5-6lRUaxEwZWVysIaFs5POHcLps2OZViwWAscaU=" + + +def _build_app( + repo: PostgresUserLlmSettingsRepository, crypto: FernetCrypto, *, user_id: str | None = _USER_ID +) -> FastAPI: + """Build a minimal FastAPI app with the LLM settings router wired to real adapters.""" + app = FastAPI() + app.include_router(llm_settings_router) + + if user_id is None: + + def _raise() -> str: + raise AuthenticationError("Invalid or missing credentials") + + app.dependency_overrides[get_current_user_id] = _raise + else: + app.dependency_overrides[get_current_user_id] = lambda: user_id + + app.dependency_overrides[get_get_user_llm_settings_use_case] = lambda: GetUserLlmSettingsUseCase(repo=repo) + app.dependency_overrides[get_upsert_user_llm_settings_use_case] = lambda: UpsertUserLlmSettingsUseCase( + repo=repo, crypto=crypto + ) + app.dependency_overrides[get_delete_user_llm_settings_use_case] = lambda: DeleteUserLlmSettingsUseCase(repo=repo) + + _register_handlers(app) + return app + + +def _register_handlers(app: FastAPI) -> None: + async def _auth_err(_req, exc: AuthenticationError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + app.add_exception_handler(AuthenticationError, _auth_err) + + +@pytest.fixture +def crypto() -> FernetCrypto: + return FernetCrypto(key=_TEST_KEY) + + +@pytest.fixture +async def repo(db_engine, crypto) -> PostgresUserLlmSettingsRepository: + return PostgresUserLlmSettingsRepository(engine=db_engine, crypto=crypto) + + +@pytest.fixture +def app(repo, crypto) -> FastAPI: + return _build_app(repo, crypto, user_id=_USER_ID) + + +@pytest.fixture +def auth_failure_app(repo, crypto) -> FastAPI: + return _build_app(repo, crypto, user_id=None) + + +class TestGetLlmSettings: + async def test_get_returns_200_none_when_absent(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/settings/llm") + assert resp.status_code == 200 + assert resp.json() is None + + async def test_get_unauthenticated_returns_401(self, auth_failure_app): + transport = ASGITransport(app=auth_failure_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/settings/llm") + assert resp.status_code == 401 + + +class TestPutLlmSettings: + async def test_put_returns_200_and_masks_api_key(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.put( + "/api/v1/settings/llm", + json={"provider": "openai", "base_url": "https://api.openai.com/v1", "api_key": "sk-secret-12345"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["provider"] == "openai" + assert body["base_url"] == "https://api.openai.com/v1" + assert body["api_key_masked"] is not None + assert "sk-secret-12345" not in body["api_key_masked"] + assert "..." in body["api_key_masked"] + + async def test_put_then_get_returns_masked(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.put( + "/api/v1/settings/llm", + json={"provider": "openrouter", "base_url": "https://openrouter.ai/v1", "api_key": "sk-abc"}, + ) + resp = await client.get("/api/v1/settings/llm") + assert resp.status_code == 200 + body = resp.json() + assert body["provider"] == "openrouter" + assert body["api_key_masked"] != "sk-abc" + + async def test_put_empty_fields_returns_422(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.put("/api/v1/settings/llm", json={"provider": "", "base_url": "", "api_key": ""}) + assert resp.status_code == 422 + + async def test_put_missing_field_returns_422(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.put("/api/v1/settings/llm", json={"provider": "openai"}) + assert resp.status_code == 422 + + async def test_put_unauthenticated_returns_401(self, auth_failure_app): + transport = ASGITransport(app=auth_failure_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.put( + "/api/v1/settings/llm", + json={"provider": "openai", "base_url": "x", "api_key": "y"}, + ) + assert resp.status_code == 401 + + +class TestDeleteLlmSettings: + async def test_delete_returns_204(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.put( + "/api/v1/settings/llm", + json={"provider": "openai", "base_url": "https://api.openai.com/v1", "api_key": "sk-test"}, + ) + resp = await client.delete("/api/v1/settings/llm") + assert resp.status_code == 204 + + async def test_delete_then_get_returns_none(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.put( + "/api/v1/settings/llm", + json={"provider": "openai", "base_url": "x", "api_key": "sk-test"}, + ) + await client.delete("/api/v1/settings/llm") + resp = await client.get("/api/v1/settings/llm") + assert resp.status_code == 200 + assert resp.json() is None + + async def test_delete_absent_returns_204(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.delete("/api/v1/settings/llm") + assert resp.status_code == 204 + + async def test_delete_unauthenticated_returns_401(self, auth_failure_app): + transport = ASGITransport(app=auth_failure_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.delete("/api/v1/settings/llm") + assert resp.status_code == 401 diff --git a/tests/unit/test_user_llm_settings_use_cases.py b/tests/unit/test_user_llm_settings_use_cases.py new file mode 100644 index 0000000..3588b62 --- /dev/null +++ b/tests/unit/test_user_llm_settings_use_cases.py @@ -0,0 +1,156 @@ +"""Tests for the user-LLM-settings use cases. + +Uses the real :class:`PostgresUserLlmSettingsRepository` (via the shared +in-memory SQLite ``db_engine`` fixture) and a real :class:`FernetCrypto` with a +fixed test key. No internal component is mocked. +""" + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.application.use_cases.user_llm_settings.delete_user_llm_settings import ( + DeleteUserLlmSettingsUseCase, +) +from src.application.use_cases.user_llm_settings.get_user_llm_settings import ( + GetUserLlmSettingsUseCase, +) +from src.application.use_cases.user_llm_settings.resolve_user_llm_credentials import ( + ResolveUserLlmCredentialsUseCase, +) +from src.application.use_cases.user_llm_settings.upsert_user_llm_settings import ( + UpsertUserLlmSettingsUseCase, +) +from src.domain.entities.user_llm_settings import UserLlmSettings, UserLlmSettingsInput +from src.infrastructure.crypto.fernet_crypto import FernetCrypto +from src.infrastructure.database.models.user_llm_setting import UserLlmSettingModel +from src.infrastructure.postgres_user_llm.adapter import PostgresUserLlmSettingsRepository + +_USER_A = "user-aaa" +_USER_B = "user-bbb" +_TEST_KEY = "Yr5R5-6lRUaxEwZWVysIaFs5POHcLps2OZViwWAscaU=" + + +@pytest.fixture +def crypto() -> FernetCrypto: + return FernetCrypto(key=_TEST_KEY) + + +@pytest.fixture +async def repo(db_engine, crypto) -> PostgresUserLlmSettingsRepository: + return PostgresUserLlmSettingsRepository(engine=db_engine, crypto=crypto) + + +@pytest.fixture +def get_uc(repo) -> GetUserLlmSettingsUseCase: + return GetUserLlmSettingsUseCase(repo=repo) + + +@pytest.fixture +def upsert_uc(repo, crypto) -> UpsertUserLlmSettingsUseCase: + return UpsertUserLlmSettingsUseCase(repo=repo, crypto=crypto) + + +@pytest.fixture +def delete_uc(repo) -> DeleteUserLlmSettingsUseCase: + return DeleteUserLlmSettingsUseCase(repo=repo) + + +@pytest.fixture +def resolve_uc(repo) -> ResolveUserLlmCredentialsUseCase: + return ResolveUserLlmCredentialsUseCase(repo=repo) + + +class TestGetUserLlmSettings: + async def test_get_returns_none_when_absent(self, get_uc): + result = await get_uc.execute(_USER_A) + assert result is None + + async def test_get_returns_settings_after_upsert(self, get_uc, upsert_uc): + await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openai", base_url="https://api.openai.com/v1", api_key="sk-test"), + ) + result = await get_uc.execute(_USER_A) + assert isinstance(result, UserLlmSettings) + assert result.provider == "openai" + assert result.base_url == "https://api.openai.com/v1" + + +class TestUpsertUserLlmSettings: + async def test_upsert_encrypts_api_key_in_db(self, upsert_uc, db_engine): + # Act + result = await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openai", base_url="https://api.openai.com/v1", api_key="sk-test-123"), + ) + + # Assert — returned settings carries masked key, NOT the plaintext + assert isinstance(result, UserLlmSettings) + assert result.api_key_masked is not None + assert "sk-test-123" not in (result.api_key_masked or "") + assert "..." in result.api_key_masked + + # Assert — DB stores an encrypted token that is NOT the plaintext + async with AsyncSession(db_engine, expire_on_commit=False) as session: + row = await session.execute(select(UserLlmSettingModel).where(UserLlmSettingModel.user_id == _USER_A)) + model = row.scalar_one() + assert model.api_key_encrypted != "sk-test-123" + assert model.provider == "openai" + assert model.base_url == "https://api.openai.com/v1" + + async def test_upsert_twice_updates_settings(self, upsert_uc, db_engine): + await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openai", base_url="https://api.openai.com/v1", api_key="sk-1"), + ) + await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openrouter", base_url="https://openrouter.ai/v1", api_key="sk-2"), + ) + async with AsyncSession(db_engine, expire_on_commit=False) as session: + rows = ( + (await session.execute(select(UserLlmSettingModel).where(UserLlmSettingModel.user_id == _USER_A))) + .scalars() + .all() + ) + assert len(rows) == 1 + assert rows[0].provider == "openrouter" + + +class TestDeleteUserLlmSettings: + async def test_delete_removes_settings(self, delete_uc, upsert_uc, get_uc): + await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openai", base_url="x", api_key="sk-x"), + ) + await delete_uc.execute(_USER_A) + assert await get_uc.execute(_USER_A) is None + + async def test_delete_absent_is_noop(self, delete_uc): + await delete_uc.execute(_USER_A) # no raise + + +class TestResolveUserLlmCredentials: + async def test_resolve_returns_none_when_absent(self, resolve_uc): + result = await resolve_uc.execute(_USER_A) + assert result is None + + async def test_resolve_returns_decrypted_tuple_after_upsert(self, resolve_uc, upsert_uc): + await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openai", base_url="https://api.openai.com/v1", api_key="sk-decrypt-me"), + ) + result = await resolve_uc.execute(_USER_A) + assert result is not None + base_url, api_key = result + assert base_url == "https://api.openai.com/v1" + # The decrypted key equals the original plaintext + assert api_key == "sk-decrypt-me" + + async def test_isolation_user_a_invisible_to_user_b(self, resolve_uc, upsert_uc): + await upsert_uc.execute( + user_id=_USER_A, + inp=UserLlmSettingsInput(provider="openai", base_url="x", api_key="sk-a"), + ) + assert await resolve_uc.execute(_USER_B) is None diff --git a/tests/unit/test_verify_credentials.py b/tests/unit/test_verify_credentials.py new file mode 100644 index 0000000..7527960 --- /dev/null +++ b/tests/unit/test_verify_credentials.py @@ -0,0 +1,189 @@ +"""End-to-end tests for the ``verify_credentials`` FastAPI dependency. + +Builds a minimal FastAPI app whose protected route depends on a +``verify_credentials`` callable. The dependency extracts the ``Authorization`` +and ``X-API-Key`` headers, calls ``AuthService.authenticate``, raises +``AuthenticationError`` when no context is returned, and otherwise sets the +RLS contextvars and returns the ``AuthContext``. + +The external boundaries (``JwtServicePort`` and ``ApiKeyRepository``) are +mocked via ``AsyncMock``; ``AuthService`` and the security wrapper are the +real internal implementations. +""" + +from unittest.mock import AsyncMock + +import pytest +from fastapi import Depends, FastAPI +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.entities.user.user import User +from src.domain.errors.codes import ErrorCode +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.security import AuthenticationError +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.domain.ports.auth.jwt_service import JwtServicePort +from src.domain.services.auth.auth_service import AuthService +from src.security import ComposableAgentsSecurity + + +def _build_security( + jwt_port: AsyncMock, + api_key_repo: AsyncMock, +) -> ComposableAgentsSecurity: + """Wire a real ``ComposableAgentsSecurity`` with a real ``AuthService``. + + The ports are ``AsyncMock``s (external boundaries); the security wrapper + and the auth service are real internal implementations. + """ + auth_service = AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + security = ComposableAgentsSecurity(master_key="") + security.set_auth_service(auth_service) # type: ignore[attr-defined] + return security + + +def _build_app(security: ComposableAgentsSecurity) -> FastAPI: + """Build a minimal FastAPI app with one ``verify_credentials``-protected route.""" + app = FastAPI() + + @app.get("/protected") + async def protected(ctx: AuthContext = Depends(security.verify_credentials)) -> dict: + return {"user_id": ctx.user_id, "method": ctx.method} + + @app.exception_handler(AuthenticationError) + async def _auth_error_handler(_request, exc: AuthenticationError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + return app + + +class TestVerifyCredentialsJwt: + """End-to-end tests for the JWT path of ``verify_credentials``.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + mock = AsyncMock(spec=JwtServicePort) + mock.decode_token.return_value = User(sub="user-jwt-1", email="a@b.c") + return mock + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + return AsyncMock(spec=ApiKeyRepository) + + @pytest.fixture + def app(self, jwt_port, api_key_repo) -> FastAPI: + security = _build_security(jwt_port, api_key_repo) + return _build_app(security) + + async def test_valid_jwt_returns_200_and_user_id(self, app: FastAPI): + # Arrange + transport = ASGITransport(app=app) + + # Act + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected", headers={"Authorization": "Bearer valid"}) + + # Assert + assert resp.status_code == 200 + assert resp.json()["user_id"] == "user-jwt-1" + assert resp.json()["method"] == "jwt" + + async def test_invalid_jwt_returns_401(self, app: FastAPI, jwt_port: AsyncMock): + # Arrange + jwt_port.decode_token.return_value = None + transport = ASGITransport(app=app) + + # Act + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected", headers={"Authorization": "Bearer bogus"}) + + # Assert + assert resp.status_code == 401 + assert resp.json()["detail"] == str(ErrorMessage.AUTH_INVALID_CREDENTIALS) + + +class TestVerifyCredentialsApiKey: + """End-to-end tests for the API-key path of ``verify_credentials``.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + return AsyncMock(spec=JwtServicePort) + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + mock = AsyncMock(spec=ApiKeyRepository) + mock.find_active_by_hash.return_value = ("user-api-1", "key-id-1") + return mock + + @pytest.fixture + def app(self, jwt_port, api_key_repo) -> FastAPI: + security = _build_security(jwt_port, api_key_repo) + return _build_app(security) + + async def test_valid_api_key_returns_200(self, app: FastAPI): + # Arrange + transport = ASGITransport(app=app) + + # Act + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected", headers={"X-API-Key": "cpk_valid"}) + + # Assert + assert resp.status_code == 200 + assert resp.json()["user_id"] == "user-api-1" + assert resp.json()["method"] == "api_key" + + async def test_wrong_api_key_returns_401(self, app: FastAPI, api_key_repo: AsyncMock): + # Arrange + api_key_repo.find_active_by_hash.return_value = None + transport = ASGITransport(app=app) + + # Act + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected", headers={"X-API-Key": "cpk_wrong"}) + + # Assert + assert resp.status_code == 401 + assert resp.json()["detail"] == str(ErrorMessage.AUTH_INVALID_CREDENTIALS) + + +class TestVerifyCredentialsMissing: + """End-to-end test with no credentials at all.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + return AsyncMock(spec=JwtServicePort) + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + return AsyncMock(spec=ApiKeyRepository) + + @pytest.fixture + def app(self, jwt_port, api_key_repo) -> FastAPI: + security = _build_security(jwt_port, api_key_repo) + return _build_app(security) + + async def test_no_credentials_returns_401(self, app: FastAPI): + # Arrange + transport = ASGITransport(app=app) + + # Act + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected") + + # Assert + assert resp.status_code == 401 + assert resp.json()["detail"] == str(ErrorMessage.AUTH_INVALID_CREDENTIALS) + + +class TestAuthenticationErrorStatusCode: + """Unit test for the ``AuthenticationError`` status code.""" + + def test_authentication_error_status_code_is_401(self) -> None: + # Act + error = AuthenticationError(ErrorMessage.AUTH_INVALID_CREDENTIALS) + + # Assert + assert int(error.status_code) == ErrorCode.UNAUTHORIZED diff --git a/tests/unit/test_verify_credentials_sets_method.py b/tests/unit/test_verify_credentials_sets_method.py new file mode 100644 index 0000000..7805032 --- /dev/null +++ b/tests/unit/test_verify_credentials_sets_method.py @@ -0,0 +1,96 @@ +"""Tests that ``verify_credentials`` sets the ``current_auth_method`` contextvar. + +The dual-auth dependency sets three contextvars on success: + +* ``current_user_id`` — the resolved user id. +* ``current_credential`` — the raw credential (JWT value or API key). +* ``current_auth_method`` — ``"jwt"`` or ``"api_key"`` matching the auth method. + +The first two are already covered by ``test_verify_credentials.py``. This file +asserts the third (``current_auth_method``) is set correctly for both paths, +so MCP credential propagation (``${USER_JWT}`` / ``${USER_API_KEY}``) can read +the method downstream. +""" + +from unittest.mock import AsyncMock + +import pytest +from fastapi import Depends, FastAPI +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.entities.user.user import User +from src.domain.errors.security import AuthenticationError +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.domain.ports.auth.jwt_service import JwtServicePort +from src.domain.services.auth.auth_service import AuthService +from src.infrastructure.database.rls_context import current_auth_method +from src.security import ComposableAgentsSecurity + + +def _build_security(jwt_port: AsyncMock, api_key_repo: AsyncMock) -> ComposableAgentsSecurity: + auth_service = AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + security = ComposableAgentsSecurity(master_key="") + security.set_auth_service(auth_service) # type: ignore[attr-defined] + return security + + +def _build_app(security: ComposableAgentsSecurity) -> FastAPI: + """Build a minimal app that returns the ``current_auth_method`` contextvar.""" + app = FastAPI() + + @app.get("/protected") + async def protected(ctx: AuthContext = Depends(security.verify_credentials)) -> dict: + return {"user_id": ctx.user_id, "method": ctx.method, "method_ctx": current_auth_method.get()} + + @app.exception_handler(AuthenticationError) + async def _auth_error_handler(_request, exc: AuthenticationError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + return app + + +class TestVerifyCredentialsSetsMethod: + """``verify_credentials`` sets ``current_auth_method`` for downstream MCP propagation.""" + + @pytest.fixture + def jwt_port(self) -> AsyncMock: + mock = AsyncMock(spec=JwtServicePort) + mock.decode_token.return_value = User(sub="user-jwt-1", email="a@b.c") + return mock + + @pytest.fixture + def api_key_repo(self) -> AsyncMock: + mock = AsyncMock(spec=ApiKeyRepository) + mock.find_active_by_hash.return_value = ("user-api-1", "key-id-1") + return mock + + @pytest.fixture + def app(self, jwt_port, api_key_repo) -> FastAPI: + security = _build_security(jwt_port, api_key_repo) + return _build_app(security) + + async def test_jwt_path_sets_method_ctx_to_jwt(self, app: FastAPI) -> None: + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected", headers={"Authorization": "Bearer valid"}) + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body["method"] == "jwt" + assert body["method_ctx"] == "jwt" + + async def test_api_key_path_sets_method_ctx_to_api_key(self, app: FastAPI, jwt_port: AsyncMock) -> None: + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/protected", headers={"X-API-Key": "cpk_valid"}) + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body["method"] == "api_key" + assert body["method_ctx"] == "api_key" diff --git a/tests/unit/test_verify_credentials_wiring.py b/tests/unit/test_verify_credentials_wiring.py new file mode 100644 index 0000000..ad1008b --- /dev/null +++ b/tests/unit/test_verify_credentials_wiring.py @@ -0,0 +1,163 @@ +"""End-to-end tests for ``verify_credentials`` wiring on the real FastAPI app. + +The real ``src.main.app`` switches its ``protected`` APIRouter from +``security.verify_api_key`` (master key) to ``security.verify_credentials`` +(dual JWT / API-key). These tests build the real app and override: + +* ``security.verify_credentials`` is NOT overridden — we exercise the real + dependency by injecting a mocked :class:`AuthService` via + ``security.set_auth_service``. +* The use cases are overridden to wired real repositories backed by the + ``db_engine`` fixture (so listing threads does not require an agent runner). +* The ``AuthenticationError`` handler is the one registered in ``src.main``. + +Scenarios: + +* ``GET /api/v1/threads`` with valid ``Authorization: Bearer …`` → 200. +* ``GET /api/v1/threads`` with no credentials → 401. +* ``GET /api/v1/threads`` with valid ``X-API-Key: cpk_…`` → 200. +* ``GET /api/v1/threads`` with wrong API key → 401. +""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from src.dependencies import ( + get_create_thread_use_case, + get_list_threads_use_case, + security, +) +from src.domain.entities.user.user import User +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.domain.ports.auth.jwt_service import JwtServicePort +from src.domain.services.auth.auth_service import AuthService + + +@pytest.fixture +def jwt_port() -> AsyncMock: + mock = AsyncMock(spec=JwtServicePort) + mock.decode_token.return_value = User(sub="u1", email="a@b.c") + return mock + + +@pytest.fixture +def api_key_repo() -> AsyncMock: + mock = AsyncMock(spec=ApiKeyRepository) + mock.find_active_by_hash.return_value = ("u1", "k1") + return mock + + +@pytest.fixture(autouse=True) +def _wire_auth_service(jwt_port, api_key_repo): + """Inject a real AuthService with mocked ports into the singleton security.""" + auth_service = AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + security.set_auth_service(auth_service) + yield + # Reset to None so other tests see the unwired state. + security._auth_service = None # noqa: SLF001 + + +@pytest_asyncio.fixture +async def app_with_overrides(db_engine) -> AsyncGenerator: + """Build the real app with use cases overridden to use the SQLite engine.""" + from src.application.use_cases.create_thread import CreateThreadUseCase + from src.application.use_cases.list_threads import ListThreadsUseCase + + # We need a stub registry that allows any agent name for create_thread. + from src.domain.ports.agent_registry import AgentRegistry + from src.domain.ports.agent_runner import AgentRunner + from src.infrastructure.postgres_thread.adapter import PostgresThreadRepository + + class _StubRegistry(AgentRegistry): + async def get_runner(self, agent_name: str) -> AgentRunner: + raise RuntimeError("not used") + + async def list_agents(self) -> list[str]: + return ["agent-x"] + + async def invalidate(self, agent_name: str) -> None: + pass + + async def close(self) -> None: + pass + + thread_repo = PostgresThreadRepository(engine=db_engine) + + from src.main import app + + app.dependency_overrides[get_create_thread_use_case] = lambda: CreateThreadUseCase(thread_repo, _StubRegistry()) + app.dependency_overrides[get_list_threads_use_case] = lambda: ListThreadsUseCase(thread_repo) + try: + yield app + finally: + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def client(app_with_overrides) -> AsyncGenerator[AsyncClient, None]: + transport = ASGITransport(app=app_with_overrides) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +class TestVerifyCredentialsWiring: + """The ``protected`` router uses ``verify_credentials`` (dual auth).""" + + async def test_valid_jwt_returns_200(self, client, jwt_port): + # Act + resp = await client.get("/api/v1/threads", headers={"Authorization": "Bearer valid"}) + + # Assert + assert resp.status_code == 200 + assert resp.json() == [] + # The JWT port was actually called + jwt_port.decode_token.assert_awaited() + + async def test_no_credentials_returns_401(self, client): + # Act + resp = await client.get("/api/v1/threads") + + # Assert + assert resp.status_code == 401 + + async def test_valid_api_key_returns_200(self, client, api_key_repo): + # Act + resp = await client.get("/api/v1/threads", headers={"X-API-Key": "cpk_valid"}) + + # Assert + assert resp.status_code == 200 + api_key_repo.find_active_by_hash.assert_awaited() + + async def test_wrong_api_key_returns_401(self, client, api_key_repo): + # Arrange + api_key_repo.find_active_by_hash.return_value = None + # Act + resp = await client.get("/api/v1/threads", headers={"X-API-Key": "cpk_wrong"}) + # Assert + assert resp.status_code == 401 + + async def test_invalid_jwt_returns_401(self, client, jwt_port): + # Arrange + jwt_port.decode_token.return_value = None + # Act + resp = await client.get("/api/v1/threads", headers={"Authorization": "Bearer bogus"}) + # Assert + assert resp.status_code == 401 + + async def test_authenticated_request_sets_user_id_contextvar(self, client, jwt_port): + """The wiring sets current_user_id so the thread is created under u1.""" + # Arrange + Act — create a thread then list + create_resp = await client.post( + "/api/v1/threads", + json={"agent_name": "agent-x"}, + headers={"Authorization": "Bearer valid"}, + ) + assert create_resp.status_code == 201 + list_resp = await client.get("/api/v1/threads", headers={"Authorization": "Bearer valid"}) + assert list_resp.status_code == 200 + # The created thread is visible to u1 + assert len(list_resp.json()) == 1 diff --git a/tests/unit/test_websocket_auth.py b/tests/unit/test_websocket_auth.py new file mode 100644 index 0000000..67d35cc --- /dev/null +++ b/tests/unit/test_websocket_auth.py @@ -0,0 +1,126 @@ +"""Tests for the WebSocket router dual auth (JWT + API key). + +The websocket router accepts either ``Authorization: Bearer `` or +``X-API-Key: ``. On success it accepts the handshake and sets the +``current_user_id`` contextvar. On failure it rejects with HTTP 401 via the +ASGI ``websocket.http.response`` extension (matching the existing master-key +behaviour). +""" + +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from src.domain.entities.user.user import User +from src.domain.ports.auth.api_key_repository import ApiKeyRepository +from src.domain.ports.auth.jwt_service import JwtServicePort +from src.domain.services.auth.auth_service import AuthService +from src.security import ComposableAgentsSecurity + + +def _build_security(jwt_port: AsyncMock, api_key_repo: AsyncMock) -> ComposableAgentsSecurity: + auth_service = AuthService(jwt_port=jwt_port, api_key_repo=api_key_repo) + security = ComposableAgentsSecurity(master_key="") + security.set_auth_service(auth_service) + return security + + +@pytest.fixture +def jwt_port() -> AsyncMock: + mock = AsyncMock(spec=JwtServicePort) + mock.decode_token.return_value = User(sub="ws-user-jwt") + return mock + + +@pytest.fixture +def api_key_repo() -> AsyncMock: + mock = AsyncMock(spec=ApiKeyRepository) + mock.find_active_by_hash.return_value = ("ws-user-key", "k1") + return mock + + +@pytest.fixture +def security(jwt_port, api_key_repo) -> ComposableAgentsSecurity: + return _build_security(jwt_port, api_key_repo) + + +def _build_app(security: ComposableAgentsSecurity): + """Build a minimal FastAPI app with one WS endpoint guarded by verify_ws.""" + from fastapi import FastAPI, WebSocket + + app = FastAPI() + + @app.websocket("/ws") + async def ws_endpoint(websocket: WebSocket) -> None: + ctx = await security.verify_credentials_ws(websocket) + if ctx is None: + return # rejected inside verify + await websocket.accept() + await websocket.send_text(f"user_id={ctx.user_id}") + await websocket.close() + + return app + + +class TestWebSocketDualAuth: + """WebSocket accepts JWT or API key; rejects otherwise.""" + + def test_jwt_accepted(self, security, jwt_port): + # Arrange + app = _build_app(security) + client = TestClient(app) + + # Act — use connect with headers + with client.websocket_connect("/ws", headers={"Authorization": "Bearer valid"}) as ws: + msg = ws.receive_text() + + # Assert + assert msg == "user_id=ws-user-jwt" + jwt_port.decode_token.assert_awaited() + + def test_api_key_accepted(self, security, api_key_repo): + # Arrange + app = _build_app(security) + client = TestClient(app) + + # Act + with client.websocket_connect("/ws", headers={"X-API-Key": "cpk_valid"}) as ws: + msg = ws.receive_text() + + # Assert + assert msg == "user_id=ws-user-key" + api_key_repo.find_active_by_hash.assert_awaited() + + def test_no_credentials_rejected_with_401(self, security): + # Arrange + app = _build_app(security) + client = TestClient(app) + + # Act / Assert — handshake rejected. Starlette raises either + # WebSocketDisconnect (older) or WebSocketDenialResponse (newer) with + # a 401 status_code on a rejected handshake. + from starlette.websockets import WebSocketDisconnect + + with pytest.raises((WebSocketDisconnect, Exception)) as exc_info, client.websocket_connect("/ws"): + pass + # The rejection carries a 401 status (WebSocketDenialResponse) or a + # close code equivalent (WebSocketDisconnect). + denial = exc_info.value + status = getattr(denial, "status_code", None) or getattr(denial, "code", None) + assert status in (401, 1008, 1006) + + def test_invalid_api_key_rejected(self, security, api_key_repo): + # Arrange + api_key_repo.find_active_by_hash.return_value = None + app = _build_app(security) + client = TestClient(app) + + # Act / Assert + from starlette.websockets import WebSocketDisconnect + + with ( + pytest.raises((WebSocketDisconnect, Exception)), + client.websocket_connect("/ws", headers={"X-API-Key": "cpk_wrong"}), + ): + pass From eb95bc2a690a0b746b94317fcd03155c8a2952a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Mon, 27 Jul 2026 17:30:03 +0200 Subject: [PATCH 2/2] feat: expose authenticated user profile via GET /api/v1/users/me Propagate email/name/username JWT claims through AuthContext (kept None for API-key auth) and expose them as a public UserProfile projection so the front can display the connected user's real name instead of a hardcoded value. - AuthContext: add optional email/name/username fields - AuthService: populate profile claims from the decoded User on the JWT path - rls_context: new current_auth_context contextvar set by verify_credentials - dependencies: get_current_auth_context + get_get_current_user_use_case - New GetCurrentUserUseCase (Router -> Use Case mapping) - New /api/v1/users/me route mounted under the protected router - Tests: auth_service claim propagation + route (JWT full/partial, API key, 401) --- src/application/routes/users.py | 43 ++++++ .../use_cases/user/get_current_user.py | 36 +++++ src/dependencies.py | 30 +++- src/domain/entities/auth/auth_context.py | 10 ++ src/domain/entities/user/user_profile.py | 27 ++++ src/domain/services/auth/auth_service.py | 9 +- src/infrastructure/database/rls_context.py | 11 ++ src/main.py | 2 + src/security.py | 9 +- tests/unit/test_auth_service.py | 35 +++++ tests/unit/test_users_routes.py | 134 ++++++++++++++++++ 11 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 src/application/routes/users.py create mode 100644 src/application/use_cases/user/get_current_user.py create mode 100644 src/domain/entities/user/user_profile.py create mode 100644 tests/unit/test_users_routes.py diff --git a/src/application/routes/users.py b/src/application/routes/users.py new file mode 100644 index 0000000..42abdd7 --- /dev/null +++ b/src/application/routes/users.py @@ -0,0 +1,43 @@ +"""HTTP routes for the authenticated user profile. + +Mounted under ``/api/v1/users``. The ``me`` endpoint requires an authenticated +:class:`AuthContext` resolved from the request (``get_current_auth_context``) +and returns a public :class:`UserProfile` projection. The use case is injected +via a FastAPI dependency so tests can override it. +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from src.application.use_cases.user.get_current_user import GetCurrentUserUseCase +from src.dependencies import get_current_auth_context, get_get_current_user_use_case +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.entities.user.user_profile import UserProfile + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +@router.get("/me", status_code=status.HTTP_200_OK) +async def get_current_user( + ctx: Annotated[AuthContext, Depends(get_current_auth_context)], + use_case: Annotated[GetCurrentUserUseCase, Depends(get_get_current_user_use_case)], +) -> UserProfile: + """Return the profile of the authenticated user. + + The profile claims (``email`` / ``name`` / ``username``) are propagated + from the JWT by the auth layer; for API-key auth only ``user_id`` is + available and the optional fields are ``null``. + + Args: + ctx: The authentication context resolved for the current request. + use_case: :class:`GetCurrentUserUseCase` wired at startup. + + Returns: + A :class:`UserProfile` carrying the user id and (when available) the + email / name / username claims. + """ + return await use_case.execute(ctx) diff --git a/src/application/use_cases/user/get_current_user.py b/src/application/use_cases/user/get_current_user.py new file mode 100644 index 0000000..8687a82 --- /dev/null +++ b/src/application/use_cases/user/get_current_user.py @@ -0,0 +1,36 @@ +"""Use case: return the profile of the authenticated user. + +Maps the :class:`~src.domain.entities.auth.auth_context.AuthContext` resolved +by the security layer to a public :class:`UserProfile` projection. Pure +mapping — no I/O, no side effects — kept as a use case so the route stays a +thin HTTP layer (Router -> Use Case). +""" + +import logging + +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.entities.user.user_profile import UserProfile + +logger = logging.getLogger(__name__) + + +class GetCurrentUserUseCase: + """Build a :class:`UserProfile` from the current :class:`AuthContext`.""" + + async def execute(self, ctx: AuthContext) -> UserProfile: + """Return the public profile of the authenticated user. + + Args: + ctx: The authentication context resolved for the current request. + + Returns: + A :class:`UserProfile` carrying the user id and (when available) + the email / name / username claims propagated from the JWT. + """ + logger.info("Current user profile requested: %s", ctx.user_id) + return UserProfile( + user_id=ctx.user_id, + email=ctx.email, + name=ctx.name, + username=ctx.username, + ) diff --git a/src/dependencies.py b/src/dependencies.py index a7441cc..18a5477 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -32,10 +32,12 @@ from src.application.use_cases.stream_message import StreamMessageUseCase from src.application.use_cases.update_agent_config import UpdateAgentConfigUseCase from src.application.use_cases.update_prompt import UpdatePromptUseCase +from src.application.use_cases.user.get_current_user import GetCurrentUserUseCase from src.application.use_cases.user_llm_settings.delete_user_llm_settings import DeleteUserLlmSettingsUseCase from src.application.use_cases.user_llm_settings.get_user_llm_settings import GetUserLlmSettingsUseCase from src.application.use_cases.user_llm_settings.upsert_user_llm_settings import UpsertUserLlmSettingsUseCase from src.config import Settings +from src.domain.entities.auth.auth_context import AuthContext from src.domain.errors.messages import ErrorMessage from src.domain.errors.security import AuthenticationError from src.domain.errors.storage import StorageError @@ -50,7 +52,7 @@ from src.domain.ports.user_llm_settings_repository import UserLlmSettingsRepository from src.infrastructure.auth.jwt_adapter import JwtAdapter from src.infrastructure.crypto.fernet_crypto import FernetCrypto -from src.infrastructure.database.rls_context import current_user_id +from src.infrastructure.database.rls_context import current_auth_context, current_user_id from src.infrastructure.mcp.adapter import LangchainMcpToolLoader from src.infrastructure.minio_store.adapter import MinioAgentConfigStore from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry @@ -561,6 +563,32 @@ def get_current_user_id() -> str: return user_id +def get_current_auth_context() -> AuthContext: + """Provide the full :class:`AuthContext` resolved for the current request. + + Set by :meth:`ComposableAgentsSecurity.verify_credentials` after a + successful JWT / API-key authentication. Carries the propagated profile + claims (``email`` / ``name`` / ``username``) on the JWT path, which the + ``GET /api/v1/users/me`` endpoint exposes. + + Returns: + The authenticated :class:`AuthContext`. + + Raises: + AuthenticationError: If no auth context is set in the current context + (e.g. the dependency is not overridden and no auth middleware ran). + """ + ctx = current_auth_context.get() + if ctx is None: + raise AuthenticationError(ErrorMessage.AUTH_INVALID_CREDENTIALS) + return ctx + + +def get_get_current_user_use_case() -> GetCurrentUserUseCase: + """Provide a :class:`GetCurrentUserUseCase` instance.""" + return GetCurrentUserUseCase() + + def _require_api_key_repository() -> ApiKeyRepository: """Return the API key repository or raise StorageError if not initialized. diff --git a/src/domain/entities/auth/auth_context.py b/src/domain/entities/auth/auth_context.py index b5c8e32..7bd23ef 100644 --- a/src/domain/entities/auth/auth_context.py +++ b/src/domain/entities/auth/auth_context.py @@ -20,8 +20,18 @@ class AuthContext(BaseModel): raw_credential: The raw credential value as received (JWT token without the ``Bearer `` prefix, or the API key plaintext). Used to set the RLS contextvar for audit / row-level security. + email: User email (optional — populated on the JWT path when the IdP + provides the ``email`` claim; ``None`` for API-key auth). + name: User full name (optional — populated on the JWT path when the + IdP provides the ``name`` claim; ``None`` for API-key auth). + username: Username (optional — populated on the JWT path when the IdP + provides the ``username`` / ``preferred_username`` claim; ``None`` + for API-key auth). """ user_id: str method: Literal["jwt", "api_key"] raw_credential: str + email: str | None = None + name: str | None = None + username: str | None = None diff --git a/src/domain/entities/user/user_profile.py b/src/domain/entities/user/user_profile.py new file mode 100644 index 0000000..e3c3af6 --- /dev/null +++ b/src/domain/entities/user/user_profile.py @@ -0,0 +1,27 @@ +"""UserProfile domain entity — public projection of the authenticated user. + +Returned by the ``GET /api/v1/users/me`` endpoint. Distinct from the +:class:`~src.domain.entities.user.user.User` entity (which models the raw JWT +payload with ``extra="ignore"``) so the API contract is explicit and decoupled +from the IdP claim shape. +""" + +from pydantic import BaseModel, ConfigDict + + +class UserProfile(BaseModel): + """Public profile of the authenticated user. + + Attributes: + user_id: Stable identifier (JWT ``sub`` or API-key owner id). + email: User email (``None`` when not provided by the credential). + name: User full name (``None`` when not provided). + username: Username (``None`` when not provided). + """ + + model_config = ConfigDict(extra="ignore") + + user_id: str + email: str | None = None + name: str | None = None + username: str | None = None diff --git a/src/domain/services/auth/auth_service.py b/src/domain/services/auth/auth_service.py index d150732..6480a3f 100644 --- a/src/domain/services/auth/auth_service.py +++ b/src/domain/services/auth/auth_service.py @@ -68,7 +68,14 @@ async def authenticate( user.sub, "jwt", ) - return AuthContext(user_id=user.sub, method="jwt", raw_credential=token) + return AuthContext( + user_id=user.sub, + method="jwt", + raw_credential=token, + email=user.email, + name=user.name, + username=user.username, + ) # Invalid JWT → no fallback to API key (matches the test contract). return None diff --git a/src/infrastructure/database/rls_context.py b/src/infrastructure/database/rls_context.py index f443da7..e35bcdd 100644 --- a/src/infrastructure/database/rls_context.py +++ b/src/infrastructure/database/rls_context.py @@ -22,6 +22,10 @@ import contextvars from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.domain.entities.auth.auth_context import AuthContext current_user_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_user_id", default=None) current_credential: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_credential", default=None) @@ -31,6 +35,13 @@ # propagation resolver (``${USER_JWT}`` / ``${USER_API_KEY}``) to decide which # credential placeholder to fill. current_auth_method: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_auth_method", default=None) +# Full AuthContext resolved for the current request (carries email/name/username +# propagated from the JWT when available). Set by +# ``ComposableAgentsSecurity.verify_credentials`` and consumed by the +# ``get_current_auth_context`` FastAPI dependency (e.g. ``GET /api/v1/users/me``). +current_auth_context: contextvars.ContextVar["AuthContext | None"] = contextvars.ContextVar( + "current_auth_context", default=None +) # When True, the RLS event listener emits ``SET LOCAL row_security = off`` so # that system/migration queries can read across all users. bypass_rls: contextvars.ContextVar[bool] = contextvars.ContextVar("bypass_rls", default=False) diff --git a/src/main.py b/src/main.py index fc501ec..8e38336 100644 --- a/src/main.py +++ b/src/main.py @@ -18,6 +18,7 @@ from src.application.routes.threads import router as threads_router from src.application.routes.trace import router as trace_router from src.application.routes.user_llm_settings import router as user_llm_settings_router +from src.application.routes.users import router as users_router from src.application.routes.websocket import router as websocket_router from src.config import Settings from src.dependencies import ( @@ -128,6 +129,7 @@ async def lifespan(_app: FastAPI): protected.include_router(store_router) protected.include_router(api_keys_router) protected.include_router(user_llm_settings_router) +protected.include_router(users_router) app.include_router(protected) diff --git a/src/security.py b/src/security.py index f51354b..1d52145 100644 --- a/src/security.py +++ b/src/security.py @@ -25,7 +25,12 @@ from src.domain.errors.messages import ErrorMessage from src.domain.errors.security import AuthenticationError, InvalidApiKeyError from src.domain.services.auth.auth_service import AuthService -from src.infrastructure.database.rls_context import current_auth_method, current_credential, current_user_id +from src.infrastructure.database.rls_context import ( + current_auth_context, + current_auth_method, + current_credential, + current_user_id, +) logger = logging.getLogger(__name__) @@ -187,6 +192,7 @@ async def verify_credentials(self, request: Request) -> AuthContext: current_user_id.set(ctx.user_id) current_credential.set(ctx.raw_credential) current_auth_method.set(ctx.method) + current_auth_context.set(ctx) return ctx async def verify_credentials_ws(self, websocket: WebSocket) -> AuthContext | None: @@ -231,4 +237,5 @@ async def verify_credentials_ws(self, websocket: WebSocket) -> AuthContext | Non current_user_id.set(ctx.user_id) current_credential.set(ctx.raw_credential) current_auth_method.set(ctx.method) + current_auth_context.set(ctx) return ctx diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py index 8738fe4..7080089 100644 --- a/tests/unit/test_auth_service.py +++ b/tests/unit/test_auth_service.py @@ -56,6 +56,37 @@ async def test_jwt_valid_returns_auth_context_with_jwt_method(self, auth_service assert result.raw_credential == "tok" jwt_port.decode_token.assert_awaited_once_with("tok") + async def test_jwt_valid_propagates_profile_claims(self, auth_service, jwt_port): + # Arrange — IdP returns a fully populated User + jwt_port.decode_token.return_value = User( + sub="user-123", + email="jane@example.com", + name="Jane Doe", + username="jane", + ) + + # Act + result = await auth_service.authenticate(authorization="Bearer tok", api_key=None) + + # Assert — email / name / username propagated to the AuthContext + assert result is not None + assert result.email == "jane@example.com" + assert result.name == "Jane Doe" + assert result.username == "jane" + + async def test_jwt_valid_with_missing_optional_claims_yields_none_profile(self, auth_service, jwt_port): + # Arrange — IdP returns only the required ``sub`` claim + jwt_port.decode_token.return_value = User(sub="user-123") + + # Act + result = await auth_service.authenticate(authorization="Bearer tok", api_key=None) + + # Assert — optional profile fields are None, not defaulted + assert result is not None + assert result.email is None + assert result.name is None + assert result.username is None + async def test_jwt_invalid_returns_none(self, auth_service, jwt_port): # Arrange jwt_port.decode_token.return_value = None @@ -105,6 +136,10 @@ async def test_api_key_valid_returns_auth_context_with_api_key_method(self, auth assert result.user_id == "user-456" assert result.method == "api_key" assert result.raw_credential == api_key + # API-key auth never carries profile claims (no JWT to decode). + assert result.email is None + assert result.name is None + assert result.username is None expected_hash = hashlib.sha256(api_key.encode()).hexdigest() api_key_repo.find_active_by_hash.assert_awaited_once_with(expected_hash) diff --git a/tests/unit/test_users_routes.py b/tests/unit/test_users_routes.py new file mode 100644 index 0000000..daa9de0 --- /dev/null +++ b/tests/unit/test_users_routes.py @@ -0,0 +1,134 @@ +"""End-to-end tests for the ``/api/v1/users/me`` router. + +Builds a minimal FastAPI app with the ``users`` router and overrides the +``get_current_auth_context`` dependency to return a fixed :class:`AuthContext` +(no real JWT / oauth2-proxy needed). Uses ``httpx.ASGITransport`` + +``AsyncClient`` so no HTTP server is started. +""" + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +from src.application.routes.users import router as users_router +from src.application.use_cases.user.get_current_user import GetCurrentUserUseCase +from src.dependencies import ( + get_current_auth_context, + get_get_current_user_use_case, +) +from src.domain.entities.auth.auth_context import AuthContext +from src.domain.errors.security import AuthenticationError + + +def _build_app(ctx: AuthContext | None) -> FastAPI: + """Build a minimal FastAPI app with the users router wired to a fixed context. + + When ``ctx`` is ``None``, the ``get_current_auth_context`` override raises + ``AuthenticationError`` so the 401 path can be exercised. + """ + app = FastAPI() + app.include_router(users_router) + + if ctx is None: + + def _raise() -> AuthContext: + raise AuthenticationError("Invalid or missing credentials") + + app.dependency_overrides[get_current_auth_context] = _raise + else: + app.dependency_overrides[get_current_auth_context] = lambda: ctx + app.dependency_overrides[get_get_current_user_use_case] = lambda: GetCurrentUserUseCase() + + async def _auth_err(_req, exc: AuthenticationError) -> JSONResponse: + return JSONResponse(status_code=int(exc.status_code), content={"detail": exc.detail}) + + app.add_exception_handler(AuthenticationError, _auth_err) + return app + + +class TestGetCurrentUserRoute: + """``GET /api/v1/users/me``.""" + + async def test_get_returns_profile_with_jwt_claims(self): + # Arrange — JWT path: email / name / username propagated + ctx = AuthContext( + user_id="user-123", + method="jwt", + raw_credential="tok", + email="jane@example.com", + name="Jane Doe", + username="jane", + ) + app = _build_app(ctx) + + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/users/me") + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body == { + "user_id": "user-123", + "email": "jane@example.com", + "name": "Jane Doe", + "username": "jane", + } + + async def test_get_returns_user_id_only_for_api_key_auth(self): + # Arrange — API-key path: no profile claims + ctx = AuthContext(user_id="user-456", method="api_key", raw_credential="cpk_xxx") + app = _build_app(ctx) + + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/users/me") + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body == { + "user_id": "user-456", + "email": None, + "name": None, + "username": None, + } + + async def test_get_with_partial_jwt_claims_returns_available_fields(self): + # Arrange — IdP provided only email (no name / username) + ctx = AuthContext( + user_id="user-789", + method="jwt", + raw_credential="tok", + email="partial@example.com", + ) + app = _build_app(ctx) + + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/users/me") + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body == { + "user_id": "user-789", + "email": "partial@example.com", + "name": None, + "username": None, + } + + async def test_get_unauthenticated_returns_401(self): + # Arrange — no auth context resolved + app = _build_app(None) + + # Act + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/users/me") + + # Assert + assert resp.status_code == 401