feat(07-05): admin AI-config endpoints + load_provider_config_by_id + atomic is_active flip

- Add GET/PUT /api/admin/ai-config + GET /api/admin/ai-config/test-connection
- _ai_config_to_dict whitelist excludes api_key_enc (T-07-01)
- PUT: HKDF-encrypt api_key, atomic UPDATE SET is_active = (provider_id = target) (T-07-03)
- PUT: upsert with PROVIDER_DEFAULTS for omitted fields; audit log records fields_changed only (T-07-14)
- All 3 endpoints require get_current_admin (T-07-15)
- Add load_provider_config_by_id to services/ai_config.py (ignores is_active)
- Promote 3 xfail tests in test_admin_ai_config.py — all 3 pass
This commit is contained in:
curo1305
2026-06-04 23:19:40 +02:00
parent fb4ce293ae
commit e678930b8d
3 changed files with 405 additions and 20 deletions
+237 -6
View File
@@ -28,14 +28,17 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, EmailStr, Field, field_validator
from sqlalchemy import func, select
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import CloudConnection, Document, Quota, RefreshToken, Topic, User
from ai import get_provider
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
from db.models import CloudConnection, Document, Quota, RefreshToken, SystemSettings, Topic, User
from deps.auth import get_current_admin
from deps.db import get_db
from deps.utils import get_client_ip
from services.ai_config import encrypt_api_key, load_provider_config_by_id
from services.audit import write_audit_log
from services.auth import hash_password, revoke_all_refresh_tokens, validate_password_strength, verify_password
from storage import get_storage_backend, get_storage_backend_for_document
@@ -48,7 +51,24 @@ _DEFAULT_QUOTA_BYTES = 104857600 # 100 MB free-tier default (D-06)
# ── Safe response helper ─────────────────────────────────────────────────────
# ── Safe response helpers ─────────────────────────────────────────────────────
def _ai_config_to_dict(row: SystemSettings) -> dict:
"""Return a safe subset of SystemSettings fields — explicitly excludes api_key_enc.
has_api_key is the ONLY indicator that a key is stored (T-07-01 mitigated).
The raw encrypted value and any decrypted plaintext are NEVER returned.
"""
return {
"provider_id": row.provider_id,
"base_url": row.base_url,
"model_name": row.model_name,
"context_chars": row.context_chars,
"is_active": row.is_active,
"has_api_key": row.api_key_enc is not None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def _user_to_dict(user: User) -> dict:
"""Return a safe subset of User fields — never includes password_hash,
@@ -99,11 +119,39 @@ class QuotaUpdate(BaseModel):
return v
class AiConfigUpdate(BaseModel):
class UserAiConfigUpdate(BaseModel):
ai_provider: Optional[str] = None
ai_model: Optional[str] = None
class SystemAiConfigUpdate(BaseModel):
"""Request model for PUT /api/admin/ai-config (system-level provider configuration).
Security: extra="forbid" prevents mass-assignment of unexpected fields (T-07-13).
provider_id is validated against PROVIDER_DEFAULTS keys (T-07-13).
api_key is write-only: when None the existing api_key_enc is left untouched,
when "" the api_key_enc is cleared, when a non-empty string it is encrypted.
"""
model_config = ConfigDict(extra="forbid")
provider_id: str
api_key: Optional[str] = None
base_url: Optional[str] = None
model_name: Optional[str] = None
context_chars: Optional[int] = None
is_active: Optional[bool] = None
@field_validator("provider_id")
@classmethod
def provider_must_be_known(cls, v: str) -> str:
if v not in PROVIDER_DEFAULTS:
raise ValueError(
f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}"
)
return v
class SystemTopicCreate(BaseModel):
"""Request model for admin system topic creation (D-09)."""
@@ -413,7 +461,7 @@ async def update_user_quota(
@router.patch("/users/{user_id}/ai-config")
async def update_ai_config(
user_id: uuid.UUID,
body: AiConfigUpdate,
body: UserAiConfigUpdate,
request: Request,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
@@ -576,3 +624,186 @@ async def create_system_topic(
session, body.name, body.description, body.color, user_id=None
)
return topic
# ── System AI Provider Configuration (D-08, D-15) ────────────────────────────
@router.get("/ai-config/test-connection")
async def test_ai_connection(
provider_id: str,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Test connectivity for an AI provider configuration (D-08).
Loads the system_settings row for the given provider_id (ignoring is_active
so admins can test inactive configurations), builds a provider instance, and
calls health_check().
Returns {"ok": true/false, "provider_id": str} — never raises 5xx for
provider-side failures (surfaces as ok=False so the UI shows a clear status).
Security: requires get_current_admin; provider_id sourced from query param,
provider instance built only from DB row (never from request body).
"""
config = await load_provider_config_by_id(session, provider_id)
if config is None:
return {"ok": False, "provider_id": provider_id, "reason": "not_configured"}
try:
provider = get_provider(config)
await provider.health_check()
return {"ok": True, "provider_id": provider_id}
except Exception:
return {"ok": False, "provider_id": provider_id, "reason": "health_check_failed"}
@router.get("/ai-config")
async def get_ai_config(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Return all AI provider configurations for the admin panel (D-08).
Includes DB rows for providers that have been saved AND synthesised stubs
for providers that only exist in PROVIDER_DEFAULTS (so the admin UI always
shows all 10 providers even before any have been configured).
Security invariant: api_key_enc is NEVER returned (T-07-01).
Use has_api_key (bool) as the only indicator that a key is stored.
"""
result = await session.execute(select(SystemSettings))
db_rows = result.scalars().all()
# Build a lookup for DB rows
db_by_provider: dict[str, SystemSettings] = {r.provider_id: r for r in db_rows}
providers_out = []
for pid in PROVIDER_DEFAULTS:
if pid in db_by_provider:
providers_out.append(_ai_config_to_dict(db_by_provider[pid]))
else:
# Synthesise a stub entry for providers with no DB row yet
defaults = PROVIDER_DEFAULTS[pid]
providers_out.append({
"provider_id": pid,
"base_url": defaults.get("base_url"),
"model_name": defaults.get("model", ""),
"context_chars": defaults.get("context_chars", 8000),
"is_active": False,
"has_api_key": False,
"updated_at": None,
})
return {"providers": providers_out}
@router.put("/ai-config")
async def update_system_ai_config(
body: SystemAiConfigUpdate,
request: Request,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Create or update a system-level AI provider configuration (D-08, D-15).
Upsert semantics: if no row exists for body.provider_id, one is created using
PROVIDER_DEFAULTS for any omitted fields.
API key handling (T-07-01 mitigated):
- body.api_key is None → leave existing api_key_enc untouched
- body.api_key == "" → clear api_key_enc (set to NULL)
- body.api_key is a non-empty string → HKDF-encrypt and store
is_active=True handling (T-07-03 mitigated):
When body.is_active is True, a single atomic UPDATE flips all rows:
SET is_active = (provider_id = :target_id)
This guarantees COUNT(WHERE is_active) == 1 with no read-then-write race.
Audit log (T-07-14 mitigated):
metadata_ contains only provider_id + fields_changed list — never the
api_key value itself.
"""
from config import settings as _settings # noqa: PLC0415
# Load existing row or create a new one from PROVIDER_DEFAULTS
stmt = select(SystemSettings).where(SystemSettings.provider_id == body.provider_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
is_new = row is None
if is_new:
defaults = PROVIDER_DEFAULTS[body.provider_id]
row = SystemSettings(
provider_id=body.provider_id,
model_name=defaults.get("model", ""),
context_chars=defaults.get("context_chars", 8000),
base_url=defaults.get("base_url"),
is_active=False,
api_key_enc=None,
)
# Track which fields the caller explicitly set (for audit log — never api_key value)
fields_changed: list[str] = []
# Apply provided fields
if body.api_key is not None:
fields_changed.append("api_key")
if body.api_key == "":
row.api_key_enc = None
else:
master_key_str = _settings.cloud_creds_key
master_key_bytes = (
master_key_str.encode("utf-8")
if isinstance(master_key_str, str)
else master_key_str
)
row.api_key_enc = encrypt_api_key(master_key_bytes, body.provider_id, body.api_key)
if body.base_url is not None:
row.base_url = body.base_url
fields_changed.append("base_url")
if body.model_name is not None:
row.model_name = body.model_name
fields_changed.append("model_name")
if body.context_chars is not None:
row.context_chars = body.context_chars
fields_changed.append("context_chars")
if body.is_active is not None:
fields_changed.append("is_active")
if is_new:
session.add(row)
await session.flush() # ensure row has an id before UPDATE
# Atomic is_active flip: SET is_active = (provider_id = :target) on ALL rows.
# Single UPDATE statement prevents dual-active race condition (T-07-03).
if body.is_active is True:
await session.execute(
update(SystemSettings).values(
is_active=(SystemSettings.provider_id == body.provider_id)
)
)
# Reflect the flip on the in-memory row
row.is_active = True
_ip_addr = get_client_ip(request)
await write_audit_log(
session,
event_type="admin.ai_config_changed",
user_id=None,
actor_id=_admin.id,
resource_id=None,
ip_address=_ip_addr,
metadata_={"provider_id": body.provider_id, "fields_changed": fields_changed},
)
await session.commit()
# Reload to pick up DB-generated updated_at after commit
await session.refresh(row)
return _ai_config_to_dict(row)