diff --git a/backend/db/models.py b/backend/db/models.py index 5e9db49..ca058bf 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -335,3 +335,43 @@ class Group(Base): created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) + + +class SystemSettings(Base): + """AI provider configuration table — one row per provider. + + Stores the active AI provider's API key (Fernet-encrypted), base URL, + model name, and context window size. Only one row has is_active=TRUE + at any given time; the admin panel flips all rows atomically. + + Encryption (D-05): + api_key_enc is encrypted with HKDF/Fernet using info=b"ai-provider-settings" + for domain separation from cloud credentials (info=b"cloud-credentials"). + Master key is settings.cloud_creds_key (env var CLOUD_CREDS_KEY). + See services/ai_config.py for the encryption helpers. + + Design reference: 07-RESEARCH.md Pattern 1. + Migration: 0005_system_settings.py. + """ + + __tablename__ = "system_settings" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + provider_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) + api_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + base_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + model_name: Mapped[str] = mapped_column(Text, nullable=False, default="") + context_chars: Mapped[int] = mapped_column(Integer, nullable=False, default=8000) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + UniqueConstraint("provider_id", name="uq_system_settings_provider_id"), + ) diff --git a/backend/migrations/versions/0005_system_settings.py b/backend/migrations/versions/0005_system_settings.py new file mode 100644 index 0000000..1d263fb --- /dev/null +++ b/backend/migrations/versions/0005_system_settings.py @@ -0,0 +1,82 @@ +"""Add system_settings table for AI provider configuration. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-06-04 + +Changes: + 1. Create system_settings table with one row per AI provider. + Stores: api_key_enc (Fernet-encrypted), base_url, model_name, + context_chars, is_active flag, and timestamps. + 2. Add UNIQUE constraint on provider_id (uq_system_settings_provider_id) + to enforce one row per provider name. + +Design notes (07-RESEARCH.md Pattern 1): + - One row per provider (not key-value store) — typed columns, enforced uniqueness. + - id column uses gen_random_uuid() server default. + - api_key_enc is Text nullable (NULL for local providers like Ollama/LMStudio). + - is_active = TRUE for the single active provider; all others FALSE. + - Encryption uses HKDF with info=b"ai-provider-settings" (domain separation from + cloud credentials which use info=b"cloud-credentials" — same master key, different + derived keys; see services/ai_config.py). +""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table("system_settings", + sa.Column( + "id", + PG_UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("provider_id", sa.String, nullable=False), + sa.Column("api_key_enc", sa.Text, nullable=True), + sa.Column("base_url", sa.Text, nullable=True), + sa.Column( + "model_name", + sa.Text, + nullable=False, + server_default="", + ), + sa.Column( + "context_chars", + sa.Integer, + nullable=False, + server_default="8000", + ), + sa.Column( + "is_active", + sa.Boolean, + nullable=False, + server_default="false", + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.UniqueConstraint("provider_id", name="uq_system_settings_provider_id"), + ) + + +def downgrade() -> None: + op.drop_table("system_settings")