ai_config was missing from PROTECTED_SETTING_KEYS, so the AI provider's api_token sat in plaintext in the settings table, and GET returned it in full to the browser. It is now Fernet-encrypted like every other integration config, and GET shows the token as ***set***. The AI clients and the model-list fallback read the decrypted value. The fallback also treats the ***set*** placeholder as "use the stored token". Rows saved in plaintext before their key was protected used to stay that way until the next edit. encrypt_plaintext_rows now encrypts them at startup, for ai_config and any key added to the list later.
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""
|
|
Settings Router
|
|
"""
|
|
import json
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel
|
|
|
|
from app.database import get_db
|
|
from app.models.audit_log import AuditEventType, AuditLog
|
|
from app.models.setting import Setting
|
|
from app.models.user import User
|
|
from app.auth.dependencies import RequireAdmin, audited
|
|
from app.auth.setting_crypto import (
|
|
PROTECTED_SETTING_KEYS,
|
|
decrypt_value,
|
|
encrypt_value,
|
|
is_protected,
|
|
read_setting_value,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/v1/settings", tags=["Settings"])
|
|
|
|
|
|
class SyslogTestRequest(BaseModel):
|
|
host: str
|
|
port: int = 514
|
|
protocol: str = "udp"
|
|
facility: int = 16
|
|
|
|
|
|
@router.post("/syslog/test", dependencies=[audited("Syslog test message", body_fields=("host", "port", "protocol"))])
|
|
def test_syslog(
|
|
payload: SyslogTestRequest,
|
|
current_user: User = Depends(RequireAdmin),
|
|
):
|
|
"""Send a one-off test message to the given syslog target. Admin only."""
|
|
from app.services.syslog_service import send_test
|
|
ok, detail = send_test(payload.model_dump())
|
|
return {"ok": ok, "detail": detail}
|
|
|
|
# Keys that may never be written through this API — owned by env vars or
|
|
# managed by the auth subsystem. Overwriting them would lock users out of
|
|
# MFA or compromise key isolation.
|
|
# custom_ca_certificates goes through /api/v1/trust-store (validation + audit).
|
|
_DENYLISTED_KEYS = {"auth_provider_crypto_key", "custom_ca_certificates"}
|
|
|
|
# Field names treated as secret-bearing inside JSON-encoded protected
|
|
# configs. Values for these subfields are redacted in GET responses; the
|
|
# admin must re-supply them on update to overwrite.
|
|
_SECRET_SUBFIELDS = {
|
|
"password", "secret", "secret_key", "access_key",
|
|
"api_key", "indexer_password", "bind_password",
|
|
"token", "smtp_password", "client_secret", "api_token",
|
|
}
|
|
|
|
|
|
def _redact_protected_value(key: str, stored: Optional[str]) -> Optional[str]:
|
|
"""Decrypt + redact secret subfields for safe GET responses."""
|
|
if not stored:
|
|
return None
|
|
try:
|
|
if not decrypt_value(stored):
|
|
return None # empty after decrypt = treated as unset
|
|
except Exception:
|
|
pass
|
|
try:
|
|
plaintext = decrypt_value(stored)
|
|
except Exception:
|
|
return "***encrypted***"
|
|
|
|
try:
|
|
data = json.loads(plaintext)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return "***redacted***"
|
|
|
|
if isinstance(data, dict):
|
|
for field in list(data.keys()):
|
|
if field.lower() in _SECRET_SUBFIELDS and data[field]:
|
|
data[field] = "***set***"
|
|
return json.dumps(data)
|
|
return "***redacted***"
|
|
|
|
|
|
def _changed_fields(old: Optional[str], new: Optional[str]) -> Optional[List[str]]:
|
|
"""Top-level keys that differ between two JSON object values — names only,
|
|
so a changed password shows up without its value. None for scalar values."""
|
|
try:
|
|
o, n = json.loads(old or "{}"), json.loads(new or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if not (isinstance(o, dict) and isinstance(n, dict)):
|
|
return None
|
|
return sorted(k for k in o.keys() | n.keys() if o.get(k) != n.get(k))
|
|
|
|
|
|
def _audit_value(key: str, plain: Optional[str]) -> Optional[str]:
|
|
"""What an audit row may show of a setting value. Secret subfields are
|
|
masked for every key, not only protected ones: an unprotected config may
|
|
still carry a token, and editors can read a resource-scoped trail."""
|
|
try:
|
|
data = json.loads(plain) if plain else None
|
|
except (json.JSONDecodeError, TypeError):
|
|
data = None
|
|
if isinstance(data, dict):
|
|
return json.dumps({k: "***set***" if k.lower() in _SECRET_SUBFIELDS and v else v
|
|
for k, v in data.items()})
|
|
return "***redacted***" if plain and is_protected(key) else plain
|
|
|
|
|
|
def _serialize(setting: Setting) -> dict:
|
|
value = setting.value
|
|
if is_protected(setting.key):
|
|
value = _redact_protected_value(setting.key, value)
|
|
return {
|
|
"key": setting.key,
|
|
"value": value,
|
|
"description": setting.description,
|
|
}
|
|
|
|
|
|
# --- Schemas ---
|
|
class SettingResponse(BaseModel):
|
|
key: str
|
|
value: Optional[str]
|
|
description: Optional[str]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
class SettingUpdate(BaseModel):
|
|
value: str
|
|
|
|
# --- Endpoints ---
|
|
|
|
@router.get("", response_model=List[SettingResponse])
|
|
def list_settings(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireAdmin)
|
|
):
|
|
"""List all system settings (secret subfields redacted)."""
|
|
return [_serialize(s) for s in db.query(Setting).all()]
|
|
|
|
@router.get("/{key}", response_model=SettingResponse)
|
|
def get_setting(
|
|
key: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireAdmin)
|
|
):
|
|
"""Get a specific setting (secret subfields redacted for protected keys)."""
|
|
setting = db.query(Setting).filter(Setting.key == key).first()
|
|
if not setting:
|
|
raise HTTPException(status_code=404, detail="Setting not found")
|
|
return _serialize(setting)
|
|
|
|
@router.put("/{key}", response_model=SettingResponse)
|
|
def update_setting(
|
|
key: str,
|
|
update_data: SettingUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireAdmin)
|
|
):
|
|
"""Update or Create a setting. Protected keys are encrypted at rest."""
|
|
if key in _DENYLISTED_KEYS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="This setting is managed outside the API and cannot be modified here",
|
|
)
|
|
|
|
existing_row = db.query(Setting).filter(Setting.key == key).first()
|
|
old_plain = read_setting_value(db, key)
|
|
|
|
value_to_store = update_data.value
|
|
if is_protected(key):
|
|
# For protected JSON configs, MERGE secret subfields: if the admin
|
|
# left a secret blank or re-submitted the redaction placeholder
|
|
# ("***set***"), keep the previously-stored secret instead of
|
|
# wiping it. Lets the operator edit non-secret fields (host, ids,
|
|
# toggles) without re-typing the password/secret every time.
|
|
try:
|
|
parsed = json.loads(update_data.value)
|
|
except json.JSONDecodeError:
|
|
parsed = None
|
|
if isinstance(parsed, dict):
|
|
existing_cfg = {}
|
|
if old_plain:
|
|
try:
|
|
existing_cfg = json.loads(old_plain)
|
|
except Exception:
|
|
existing_cfg = {}
|
|
for f in list(parsed.keys()):
|
|
if f.lower() in _SECRET_SUBFIELDS and (parsed[f] in ("", "***set***", None)):
|
|
if existing_cfg.get(f):
|
|
parsed[f] = existing_cfg[f] # preserve stored secret
|
|
else:
|
|
parsed.pop(f, None) # nothing to keep → drop
|
|
update_data.value = json.dumps(parsed)
|
|
value_to_store = encrypt_value(update_data.value)
|
|
|
|
setting = existing_row
|
|
if not setting:
|
|
setting = Setting(key=key, value=value_to_store)
|
|
db.add(setting)
|
|
else:
|
|
setting.value = value_to_store
|
|
|
|
fields = _changed_fields(old_plain, update_data.value)
|
|
if fields or (fields is None and (update_data.value or "") != (old_plain or "")):
|
|
db.add(AuditLog(
|
|
user_id=current_user.id,
|
|
event_type=AuditEventType.CONFIG_CHANGE,
|
|
event_description=(f"Setting '{key}' changed"
|
|
+ (f": {', '.join(fields)}" if fields else ""))[:500],
|
|
resource_type="setting",
|
|
resource_id=key[:50],
|
|
old_value=_audit_value(key, old_plain),
|
|
new_value=_audit_value(key, update_data.value),
|
|
timestamp=datetime.now(),
|
|
))
|
|
|
|
db.commit()
|
|
db.refresh(setting)
|
|
return _serialize(setting)
|
|
|
|
|
|
# ---------- live model discovery ----------
|
|
#
|
|
# The model dropdown used to be a hand-written list per provider, so it was
|
|
# wrong the day after every release — DeepSeek-V3 sat there while V3.2 shipped,
|
|
# and picking a model the provider had retired failed only at generation time.
|
|
# Ask the provider instead: every one of these exposes a model list.
|
|
#
|
|
# The key travels in the request BODY, never a query string — URLs end up in
|
|
# proxy and access logs.
|
|
class AIModelsRequest(BaseModel):
|
|
provider: str
|
|
api_token: Optional[str] = None # omit to use the saved one
|
|
base_url: Optional[str] = None
|
|
|
|
|
|
@router.post("/ai/models", dependencies=[audited("AI model list fetched", body_fields=("provider", "base_url"))])
|
|
async def list_ai_models(
|
|
body: AIModelsRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireAdmin),
|
|
):
|
|
"""Fetch the models the configured provider currently offers."""
|
|
import json as _json
|
|
import httpx
|
|
|
|
provider = (body.provider or "").strip().lower()
|
|
token = (body.api_token or "").strip()
|
|
if not token or token == "***set***":
|
|
# Fall back to what is stored, so the dropdown works without retyping
|
|
# (the UI only ever sees the redacted placeholder).
|
|
stored = read_setting_value(db, "ai_config")
|
|
token = ""
|
|
if stored:
|
|
try:
|
|
token = (_json.loads(stored) or {}).get("api_token") or ""
|
|
except (ValueError, TypeError):
|
|
token = ""
|
|
base = (body.base_url or "").strip().rstrip("/")
|
|
|
|
# (url, headers, json-path to the list, key holding the model id)
|
|
if provider in ("openai", "deepseek", "openrouter", "groq", "mistral"):
|
|
default_base = {
|
|
"openai": "https://api.openai.com/v1",
|
|
"deepseek": "https://api.deepseek.com",
|
|
"openrouter": "https://openrouter.ai/api/v1",
|
|
"groq": "https://api.groq.com/openai/v1",
|
|
"mistral": "https://api.mistral.ai/v1",
|
|
}[provider]
|
|
url = f"{base or default_base}/models"
|
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
path, id_key = ("data",), "id"
|
|
elif provider == "anthropic":
|
|
url = f"{base or 'https://api.anthropic.com/v1'}/models"
|
|
headers = {"x-api-key": token, "anthropic-version": "2023-06-01"}
|
|
path, id_key = ("data",), "id"
|
|
elif provider == "gemini":
|
|
# Google takes the key in a header too — keeps it out of the URL.
|
|
url = f"{base or 'https://generativelanguage.googleapis.com/v1beta'}/models"
|
|
headers = {"x-goog-api-key": token}
|
|
path, id_key = ("models",), "name"
|
|
elif provider == "ollama":
|
|
# Local daemon, no key. Its list lives outside the OpenAI-shaped API.
|
|
root = (base or "http://localhost:11434").replace("/v1/chat/completions", "")
|
|
url = f"{root.rstrip('/')}/api/tags"
|
|
headers = {}
|
|
path, id_key = ("models",), "name"
|
|
else:
|
|
raise HTTPException(400, f"Live model listing is not supported for '{provider}'")
|
|
|
|
if not token and provider not in ("ollama", "openrouter"):
|
|
raise HTTPException(400, "Enter the API key first, then load the models")
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
r = await client.get(url, headers=headers)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach {provider}: {e}")
|
|
if r.status_code == 401 or r.status_code == 403:
|
|
raise HTTPException(401, f"{provider} rejected the API key")
|
|
if r.status_code >= 400:
|
|
raise HTTPException(502, f"{provider} returned {r.status_code}: {r.text[:200]}")
|
|
|
|
try:
|
|
payload = r.json()
|
|
except ValueError:
|
|
raise HTTPException(502, f"{provider} returned a non-JSON response")
|
|
items = payload
|
|
for step in path:
|
|
items = (items or {}).get(step) if isinstance(items, dict) else None
|
|
if not isinstance(items, list):
|
|
raise HTTPException(502, f"Unexpected model list shape from {provider}")
|
|
|
|
models = []
|
|
for it in items:
|
|
if not isinstance(it, dict):
|
|
continue
|
|
mid = it.get(id_key)
|
|
if not mid:
|
|
continue
|
|
# Gemini returns "models/gemini-3-pro" — the API wants the bare id.
|
|
mid = str(mid).split("/")[-1] if provider == "gemini" else str(mid)
|
|
models.append({"id": mid, "label": it.get("display_name") or it.get("name") or mid})
|
|
# Newest first where the provider says so; otherwise stable alphabetical.
|
|
models.sort(key=lambda m: m["id"])
|
|
return {"provider": provider, "models": models, "count": len(models)}
|