Files
curo1305 6d1c02f703 refactor(09-05): CODE-09 purge — Phase 8 backend sub-packages (WHAT comments removed)
- api/admin/users.py: removed module docstring + 7 WHAT function docstrings
- api/admin/quotas.py: removed module docstring + 2 WHAT function docstrings
- api/admin/ai.py: removed module docstring + 3 WHAT inline comments; security invariant docstrings preserved
- api/admin/shared.py: unchanged (all comments are WHY — constraint notes)
- api/documents/upload.py: removed 4 WHAT inline comments; T-03-05/T-03-06 WHY notes preserved
- api/documents/content.py: removed WHAT function docstring + WHAT inline comment
- api/documents/crud.py: removed 7 WHAT inline comments; D-16 + security constraint docstrings preserved
- api/auth/shared.py: removed module docstring + 1 WHAT inline comment
- api/auth/tokens.py: removed module docstring + 8 WHAT function docstrings/comments; family-revocation + SEC-02 WHY notes preserved
- api/auth/totp.py: removed module docstring + 2 WHAT function docstrings + 2 WHAT inline comments
- api/auth/password.py: removed module docstring + 2 WHAT function docstrings + 3 WHAT inline comments
- All NO-prefix anchor comments and security-invariant WHY comments preserved
- pytest: 413 passed, 1 failed (pre-existing ModuleNotFoundError unrelated to purge)
2026-06-12 16:59:46 +02:00

354 lines
13 KiB
Python

from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ai import get_provider
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
from db.models import SystemSettings, 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, validate_provider_id
from services.audit import write_audit_log
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
# ── Local helper (only ai.py uses this — not in shared.py) ───────────────────
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,
}
# ── Request models ────────────────────────────────────────────────────────────
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:
return validate_provider_id(v)
class TestConnectionRequest(BaseModel):
"""Request body for POST /api/admin/ai-config/test-connection.
Unsaved form values (api_key, base_url, model_name) override the DB row so
admins can verify credentials before saving. All override fields are optional;
omitting them falls back to whatever is stored in system_settings.
"""
model_config = ConfigDict(extra="forbid")
provider_id: str
api_key: Optional[str] = None # If non-empty, used instead of stored api_key_enc
base_url: Optional[str] = None # If non-None, overrides DB base_url
model_name: Optional[str] = None # If non-empty, overrides DB model_name
@field_validator("provider_id")
@classmethod
def provider_must_be_known(cls, v: str) -> str:
return validate_provider_id(v)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/ai-config/models")
async def get_ai_config_models(
provider_id: str,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Return the list of model IDs available from a provider's API (D-08).
Calls the provider's standard GET /models endpoint using the stored
config (base_url + api_key from system_settings). Always returns 200
with {"models": [...]} — never 5xx on provider failure (returns empty list).
Security: requires get_current_admin; provider_id from query param only;
decrypted api_key never appears in the response.
"""
import httpx # noqa: PLC0415 — local import keeps startup fast
config = await load_provider_config_by_id(session, provider_id)
# Resolve base_url: prefer DB row, fall back to PROVIDER_DEFAULTS
if config and config.base_url:
base_url = config.base_url.rstrip("/")
else:
base_url = (PROVIDER_DEFAULTS.get(provider_id, {}).get("base_url") or "").rstrip("/")
if not base_url:
return {"models": [], "provider_id": provider_id}
api_key = config.api_key if config else ""
# Anthropic uses x-api-key; all others use Bearer (different auth schemes)
if provider_id == "anthropic":
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
models_url = "https://api.anthropic.com/v1/models"
else:
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
models_url = f"{base_url}/models"
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(models_url, headers=headers)
resp.raise_for_status()
data = resp.json()
raw_list = data.get("data") or data.get("models") or []
model_ids: list[str] = sorted(
{
item["id"] if isinstance(item, dict) else str(item)
for item in raw_list
if item
}
)
return {"models": model_ids, "provider_id": provider_id}
except Exception as exc:
return {"models": [], "provider_id": provider_id, "error": str(exc)[:120]}
@router.post("/ai-config/test-connection")
async def test_ai_connection(
body: TestConnectionRequest,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Test connectivity for an AI provider, optionally with unsaved form values (D-08).
Loads the stored system_settings row for body.provider_id, then overlays any
non-empty values from the request body so admins can verify credentials before
saving them to the database.
Override priority (highest -> lowest):
1. body.api_key / base_url / model_name (unsaved form values)
2. system_settings DB row (previously saved config)
3. PROVIDER_DEFAULTS (built-in fallback)
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; api_key from body is used only for the
in-flight health_check() call and is never stored or logged.
"""
provider_id = body.provider_id
stored = await load_provider_config_by_id(session, provider_id)
defaults = PROVIDER_DEFAULTS.get(provider_id, {})
# Resolve effective values: body overrides DB, DB overrides PROVIDER_DEFAULTS
effective_api_key = (
body.api_key
if body.api_key
else (stored.api_key if stored else "")
)
effective_base_url = (
body.base_url
if body.base_url is not None
else (stored.base_url if stored else defaults.get("base_url"))
)
effective_model = (
body.model_name
if body.model_name
else (stored.model if stored else defaults.get("model", ""))
)
effective_config = ProviderConfig(
provider_id=provider_id,
api_key=effective_api_key,
base_url=effective_base_url,
model=effective_model,
)
try:
provider = get_provider(effective_config)
ok = await provider.health_check()
return {"ok": ok, "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
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,
)
fields_changed: list[str] = []
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()
await session.refresh(row)
return _ai_config_to_dict(row)