Files
vulncheck/app/routers/settings.py
T
vulncheck 4bc3633551 fix(settings): encrypt the AI provider token at rest
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.
2026-11-04 09:03:36 +01:00

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)}