Files
siftlode/backend/app/routes/config.py
T
peter 6c63df6704 feat: one structured log for every process, with a request id
A deployment printed four different log formats and none of them could be
joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in
main.py for anything not started by uvicorn, logging.basicConfig() in the worker
and in the download child (no timestamp at all), and alembic's fileConfig.
app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log
data model, no SDK): ts, level, severity, source, logger, event code, message,
attributes, plus the correlation the context carries — request id, account,
download job.

Redaction is applied at the HANDLER, not at the call sites: every message, every
attribute and every traceback is scrubbed on the way out, so a secret cannot
leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies,
secret config values and email addresses never reach a line.

The access log is ours now (`http.request`): the route template instead of the
concrete path, the status, the duration and the account — and never the query
string, which uvicorn's access log printed on every single request (`?account=`,
`?source=`, the `?token=` of a share link). A well-formed client request id is
accepted and echoed in X-Request-Id, so a browser line and a server line join up.
The account comes from `request.state`, filled by the auth dependency: the
middleware is the outermost one, so it runs outside SessionMiddleware and in a
different task context.

`log_level` becomes an admin knob (sysconfig), applied without a restart and
re-applied at startup — best-effort on purpose, since a logging knob must never
be what stops the app from booting. Every compose service gets bounded json-file
logs (3×50 MB): Docker keeps every line forever by default, and the API now
writes one line per request.

Verified on the localdev stack: JSON everywhere including alembic and the
worker, the query strings gone, a supplied X-Request-Id echoed and logged, and
three /healthz hits of which only the one made while the level was DEBUG
produced a line.
2026-09-16 01:37:02 +02:00

134 lines
4.9 KiB
Python

"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in
app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned."""
import re
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import logs
from app import mailtext
from app import sysconfig
from app.audit import AuditAction
from app.auth import admin_user
from app.db import get_db
from app.models import User
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
# Strip URL userinfo (user:pass@) so a non-secret setting that happens to embed credentials —
# e.g. an authenticated egress proxy http://user:pass@host:port — never lands plaintext in the
# audit trail. Non-secret values are shown live in the UI, but the log shouldn't retain creds.
_URL_USERINFO = re.compile(r"(://)[^/@\s]+@")
def _redact(v):
return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else v
def _apply_runtime(db: Session, key: str) -> None:
"""Some knobs change how THIS process behaves, not just what a later read returns — they have to
take effect now, without a restart. One dispatch for both call sites (set and reset), so the next
such key can't be wired into one of them and forgotten in the other."""
if key == "log_level":
logs.set_level(sysconfig.get_str(db, "log_level"))
@router.get("")
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
return sysconfig.describe(db)
class ConfigSetIn(BaseModel):
# A config value is arbitrarily typed (str/int/bool/…), validated downstream by
# sysconfig.set_value. `value` defaults to a sentinel so a MISSING value stays a 400
# ("Missing 'value'") while an explicit `null` is a real, settable value.
value: Any = None
@router.patch("/{key}")
def set_config(
key: str,
body: ConfigSetIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
if s is None:
raise HTTPException(status_code=404, detail="Unknown config key")
if "value" not in body.model_fields_set:
raise HTTPException(status_code=400, detail="Missing 'value'")
if s.secret and not sysconfig.secrets_manageable():
raise HTTPException(
status_code=400,
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
)
old = None if s.secret else sysconfig.get(db, key)
try:
sysconfig.set_value(db, key, body.value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
# are write-only so we can't compare — always log.
new = None if s.secret else sysconfig.get(db, key)
if s.secret or new != old:
audit.record(
db,
admin.id,
AuditAction.CONFIG_SET,
target_type="config",
target_id=key,
summary=f"{key} = (secret updated)" if s.secret else f"{key} = {_redact(new)}",
before=None if s.secret else {"value": _redact(old)},
after=None if s.secret else {"value": _redact(new)},
)
db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db)
@router.delete("/{key}")
def reset_config(
key: str,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
s = sysconfig.spec(key)
if s is None:
raise HTTPException(status_code=404, detail="Unknown config key")
old = None if s.secret else sysconfig.get(db, key)
sysconfig.reset(db, key)
audit.record(
db,
admin.id,
AuditAction.CONFIG_RESET,
target_type="config",
target_id=key,
summary=f"{key} → default",
before=None if s.secret else {"value": _redact(old)},
)
db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db)
@router.post("/test-email")
def send_test_email(
user: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
lets the admin verify the settings actually work."""
if not email_mod.email_enabled():
raise HTTPException(status_code=400, detail="SMTP is not configured.")
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
if not sent:
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
return {"sent": True, "to": user.email}