Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
609 changes: 550 additions & 59 deletions README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion agents/single/haiku-files-local-structured.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,5 @@ mcp_servers:
transport: http
url: http://raganything-api:8000/bricks/mcp
headers:
X-API-Key: "${MCP_RAGANYTHING_API_KEY}"
Authorization: "Bearer ${USER_JWT}"
X-API-Key: "${USER_API_KEY}"
3 changes: 2 additions & 1 deletion agents/single/haiku-files-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,5 @@ mcp_servers:
transport: http
url: http://raganything-api:8000/bricks/mcp
headers:
X-API-Key: "${MCP_RAGANYTHING_API_KEY}"
Authorization: "Bearer ${USER_JWT}"
X-API-Key: "${USER_API_KEY}"
3 changes: 3 additions & 0 deletions agents/single/haiku-rag-formation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
3 changes: 2 additions & 1 deletion agents/single/haiku-rag-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ mcp_servers:
transport: http
url: http://raganything-api:8000/classical/mcp
headers:
X-API-Key: "${MCP_RAGANYTHING_API_KEY}"
Authorization: "Bearer ${USER_JWT}"
X-API-Key: "${USER_API_KEY}"
3 changes: 2 additions & 1 deletion agents/single/haiku-rag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,5 @@ mcp_servers:
transport: http
url: https://raganything.soludev.tech/classical/mcp
headers:
X-API-Key: "${MCP_RAGANYTHING_API_KEY}"
Authorization: "Bearer ${USER_JWT}"
X-API-Key: "${USER_API_KEY}"
2 changes: 2 additions & 0 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/alembic/versions/011_create_api_keys_table.py
Original file line number Diff line number Diff line change
@@ -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;")
54 changes: 54 additions & 0 deletions src/alembic/versions/012_add_user_id_to_rls_tables.py
Original file line number Diff line number Diff line change
@@ -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;")
64 changes: 64 additions & 0 deletions src/alembic/versions/013_enable_rls_policies.py
Original file line number Diff line number Diff line change
@@ -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;")
64 changes: 64 additions & 0 deletions src/alembic/versions/014_create_user_llm_settings_table.py
Original file line number Diff line number Diff line change
@@ -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};")
13 changes: 13 additions & 0 deletions src/application/requests/api_key.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions src/application/requests/user_llm_settings.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading