- 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)
354 lines
13 KiB
Python
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)
|