config_store.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. """Configuration store: load, validate, atomically write, and hot-reload config.toml.
  2. Design notes (see AGENTS.md):
  3. * **tomlkit, not tomllib/tomli-w.** The admin panel writes this file back, and the file
  4. is heavily commented. tomlkit round-trips comments/ordering; a plain dict dump would
  5. destroy them on the first save.
  6. * **Immutable snapshots.** Config is never mutated in place. `apply()` builds a new
  7. frozen `Snapshot` and rebinds one attribute — atomic under the GIL, so readers need
  8. no lock. Views take one snapshot per request; the worker takes one per job. A job
  9. therefore runs under a single consistent config and an admin edit affects the *next*
  10. job, which removes every mid-job race without locking.
  11. * **Write-only secrets.** `redacted()` never emits a secret value; `apply()` treats a
  12. blank/absent secret as "leave unchanged".
  13. * **PPSQ_CONFIG** selects an alternate file. Tests rely on this to never touch the real
  14. config.toml.
  15. """
  16. from __future__ import annotations
  17. import copy
  18. import logging
  19. import os
  20. import shutil
  21. import tempfile
  22. import threading
  23. from dataclasses import dataclass
  24. from pathlib import Path
  25. from types import MappingProxyType
  26. from typing import Any, Mapping
  27. from urllib.parse import urlparse
  28. import tomlkit
  29. from pps_client import PPSClient
  30. log = logging.getLogger("pps.config")
  31. # Dotted keys the admin panel may write. An explicit ALLOWLIST — never a denylist.
  32. ADMIN_EDITABLE: frozenset[str] = frozenset(
  33. {
  34. "pps.base_url", "pps.username", "pps.password", "pps.verify_tls",
  35. "pps.timeout", "pps.client_cert", "pps.client_key",
  36. "quarantine.default_folder", "quarantine.folders", "quarantine.deleted_folder",
  37. "quarantine.default_limit", "quarantine.list_query",
  38. "quarantine.default_days_back", "quarantine.chunk_size",
  39. "quarantine.default_sort_field", "quarantine.default_sort_dir",
  40. "quarantine.report_release.steps", "quarantine.report_release.move_target",
  41. "quarantine.report_release.step_delay_seconds",
  42. "auth.denied_message", "auth.users",
  43. "app.log_level",
  44. }
  45. )
  46. # Written to disk but only picked up on restart; reported back so the UI can say so.
  47. RESTART_KEYS: frozenset[str] = frozenset(
  48. {
  49. "app.secret_key", "app.listen", "app.port", "app.db_path",
  50. "app.worker_log", "app.app_log", "app.cookie_secure",
  51. "auth.mode",
  52. }
  53. )
  54. # Never leave the process. `redacted()` emits a `<name>_set` boolean instead.
  55. SECRET_KEYS: frozenset[str] = frozenset(
  56. {"pps.password", "okta.client_secret", "auth.static.password", "app.secret_key"}
  57. )
  58. # Connection identity — a change here means the PPSClient must be rebuilt.
  59. _PPS_FINGERPRINT = (
  60. "base_url", "username", "password", "verify_tls", "timeout", "client_cert", "client_key",
  61. )
  62. # Report & Release step order before it flipped to move-first (`pipeline.ALLOWED_STEPS`).
  63. # Configs written against it are migrated, not rejected — see `_migrate`.
  64. _LEGACY_STEP_ORDER: tuple[str, ...] = ("release", "move", "delete")
  65. # Steps the pipeline used to offer and no longer does; dropped from old configs on load.
  66. _RETIRED_STEPS: frozenset[str] = frozenset({"delete"})
  67. _SORT_FIELDS = frozenset({"subject", "date", "from", "rcpt"})
  68. _SORT_DIRS = frozenset({"asc", "desc"})
  69. _ROLES = frozenset({"admin", "user"})
  70. class ConfigError(Exception):
  71. """Validation failure. Carries per-field messages for a 400 response."""
  72. def __init__(self, errors: list[dict[str, str]]):
  73. self.errors = errors
  74. super().__init__("; ".join(f"{e['field']}: {e['message']}" for e in errors))
  75. @dataclass(frozen=True)
  76. class Snapshot:
  77. version: int
  78. pps: Mapping[str, Any]
  79. quarantine: Mapping[str, Any]
  80. app: Mapping[str, Any]
  81. auth: Mapping[str, Any]
  82. okta: Mapping[str, Any]
  83. path: Path
  84. @dataclass(frozen=True)
  85. class ApplyResult:
  86. version: int
  87. applied: list[str]
  88. restart_required: list[str]
  89. def _freeze(d: Any) -> Any:
  90. """Deep-copy plain data and wrap mappings read-only, so a snapshot can't be mutated."""
  91. if isinstance(d, Mapping):
  92. return MappingProxyType({k: _freeze(v) for k, v in d.items()})
  93. if isinstance(d, (list, tuple)):
  94. return tuple(_freeze(v) for v in d)
  95. return d
  96. def _plain(doc: Any) -> Any:
  97. """tomlkit containers -> plain python (dict/list/scalars)."""
  98. if isinstance(doc, Mapping):
  99. return {k: _plain(v) for k, v in doc.items()}
  100. if isinstance(doc, (list, tuple)):
  101. return [_plain(v) for v in doc]
  102. return doc
  103. def _dig(d: Mapping, dotted: str, default=None):
  104. cur: Any = d
  105. for part in dotted.split("."):
  106. if not isinstance(cur, Mapping) or part not in cur:
  107. return default
  108. cur = cur[part]
  109. return cur
  110. def _flatten(d: Mapping, prefix: str = "") -> dict[str, Any]:
  111. """Flatten nested dicts to dotted keys. Lists are leaves (e.g. quarantine.folders)."""
  112. out: dict[str, Any] = {}
  113. for k, v in d.items():
  114. key = f"{prefix}{k}"
  115. if isinstance(v, Mapping):
  116. out.update(_flatten(v, f"{key}."))
  117. else:
  118. out[key] = v
  119. return out
  120. def resolve_config_path(explicit: str | Path | None = None) -> Path:
  121. """Explicit arg -> $PPSQ_CONFIG -> ./config.toml (next to this module)."""
  122. if explicit:
  123. return Path(explicit)
  124. env = os.environ.get("PPSQ_CONFIG")
  125. if env:
  126. return Path(env)
  127. return Path(__file__).with_name("config.toml")
  128. class ConfigStore:
  129. def __init__(self, path: str | Path | None = None):
  130. # Guard: a test that forgets PPSQ_CONFIG must never touch the real config.toml.
  131. if os.environ.get("PYTEST_CURRENT_TEST") and not (path or os.environ.get("PPSQ_CONFIG")):
  132. raise RuntimeError(
  133. "Refusing to open the default config.toml under pytest. "
  134. "Set PPSQ_CONFIG to a temp copy (see tests/conftest.py)."
  135. )
  136. self.path = resolve_config_path(path)
  137. if not self.path.exists():
  138. raise SystemExit(
  139. f"Missing {self.path}. Copy config.example.toml to config.toml and edit it."
  140. )
  141. self._lock = threading.RLock()
  142. self._version = 0
  143. doc = self._read_doc()
  144. data = _migrate(_plain(doc))
  145. _validate(data)
  146. self._snapshot = self._build_snapshot(data)
  147. self._pps_fp: tuple | None = None
  148. self._pps: PPSClient | None = None
  149. self._rebuild_pps(data)
  150. # ------------------------------------------------------------------ reading
  151. def snapshot(self) -> Snapshot:
  152. """Current config. Lock-free: attribute reads are atomic under the GIL."""
  153. return self._snapshot
  154. def pps(self) -> PPSClient:
  155. return self._pps # type: ignore[return-value]
  156. def redacted(self) -> dict:
  157. """Admin-panel payload. Secrets are replaced by a `<name>_set` boolean."""
  158. snap = self._snapshot
  159. data = {
  160. "pps": dict(_plain(snap.pps)),
  161. "quarantine": dict(_plain(snap.quarantine)),
  162. "app": dict(_plain(snap.app)),
  163. "auth": dict(_plain(snap.auth)),
  164. }
  165. for dotted in SECRET_KEYS:
  166. section, _, leaf = dotted.rpartition(".")
  167. parent = _dig(data, section) if section else data
  168. if isinstance(parent, dict) and leaf in parent:
  169. parent[f"{leaf}_set"] = bool(parent.pop(leaf))
  170. # okta is server-only: expose nothing but whether it is configured.
  171. data["okta"] = {"configured": bool(_dig(snap.okta, "client_id"))}
  172. return data
  173. # ------------------------------------------------------------------ writing
  174. def apply(self, patch: dict, *, actor: str = "system") -> ApplyResult:
  175. """Validate + atomically write a nested patch, then swap in a new snapshot."""
  176. with self._lock:
  177. flat = _flatten(patch)
  178. rejected = [k for k in flat if k not in ADMIN_EDITABLE]
  179. if rejected:
  180. raise ConfigError(
  181. [{"field": k, "message": "unknown or non-editable key"} for k in rejected]
  182. )
  183. doc = self._read_doc()
  184. current = _migrate(_plain(doc))
  185. merged = copy.deepcopy(current)
  186. applied: list[str] = []
  187. for dotted, value in flat.items():
  188. # Blank secret = leave unchanged (write-only fields).
  189. if dotted in SECRET_KEYS and (value is None or value == ""):
  190. continue
  191. if _dig(merged, dotted) == value:
  192. continue
  193. _set_dotted(merged, dotted, value)
  194. applied.append(dotted)
  195. if not applied:
  196. return ApplyResult(self._version, [], [])
  197. # Normalise the merged result (e.g. a step list still in the pre-flip
  198. # release-before-move order) before validating and writing it.
  199. _migrate(merged)
  200. _validate(merged)
  201. for dotted in applied:
  202. _set_dotted_doc(doc, dotted, _dig(merged, dotted))
  203. self._write_atomic(doc)
  204. self._rebuild_pps(merged)
  205. self._snapshot = self._build_snapshot(merged)
  206. log.info("config updated by %s: %s", actor, ", ".join(sorted(applied)))
  207. return ApplyResult(
  208. version=self._snapshot.version,
  209. applied=sorted(applied),
  210. restart_required=sorted(k for k in applied if k in RESTART_KEYS),
  211. )
  212. def reload_from_disk(self) -> Snapshot:
  213. with self._lock:
  214. data = _migrate(_plain(self._read_doc()))
  215. _validate(data)
  216. self._rebuild_pps(data)
  217. self._snapshot = self._build_snapshot(data)
  218. log.info("config reloaded from %s (version %d)", self.path, self._snapshot.version)
  219. return self._snapshot
  220. # ------------------------------------------------------------------ internals
  221. def _read_doc(self):
  222. with self.path.open("r", encoding="utf-8") as fh:
  223. return tomlkit.parse(fh.read())
  224. def _build_snapshot(self, data: dict) -> Snapshot:
  225. self._version += 1
  226. return Snapshot(
  227. version=self._version,
  228. pps=_freeze(data.get("pps", {})),
  229. quarantine=_freeze(data.get("quarantine", {})),
  230. app=_freeze(data.get("app", {})),
  231. auth=_freeze(data.get("auth", {})),
  232. okta=_freeze(data.get("okta", {})),
  233. path=self.path,
  234. )
  235. def _rebuild_pps(self, data: dict) -> None:
  236. """Rebuild the PPS client only when connection identity changed."""
  237. p = data.get("pps", {})
  238. fp = tuple(p.get(k) for k in _PPS_FINGERPRINT)
  239. if fp == self._pps_fp and self._pps is not None:
  240. return
  241. # Rebind the client before the snapshot; in-flight requests keep their own ref.
  242. self._pps = PPSClient(
  243. base_url=p["base_url"],
  244. username=p["username"],
  245. password=p["password"],
  246. verify_tls=p.get("verify_tls", False),
  247. timeout=int(p.get("timeout", 120)),
  248. client_cert=p.get("client_cert") or None,
  249. client_key=p.get("client_key") or None,
  250. )
  251. self._pps_fp = fp
  252. def _write_atomic(self, doc) -> None:
  253. """Temp file in the same dir -> fsync -> backup -> atomic rename -> fsync dir."""
  254. parent = self.path.parent
  255. fd, tmp = tempfile.mkstemp(dir=parent, prefix=".config.", suffix=".toml.tmp")
  256. try:
  257. os.fchmod(fd, 0o600)
  258. with os.fdopen(fd, "w", encoding="utf-8") as fh:
  259. fh.write(tomlkit.dumps(doc))
  260. fh.flush()
  261. os.fsync(fh.fileno())
  262. if self.path.exists():
  263. shutil.copy2(self.path, self.path.with_name(self.path.name + ".bak"))
  264. os.replace(tmp, self.path) # same filesystem by construction
  265. tmp = None
  266. dfd = os.open(parent, os.O_DIRECTORY)
  267. try:
  268. os.fsync(dfd)
  269. finally:
  270. os.close(dfd)
  271. finally:
  272. if tmp:
  273. Path(tmp).unlink(missing_ok=True)
  274. def _set_dotted(d: dict, dotted: str, value) -> None:
  275. parts = dotted.split(".")
  276. cur = d
  277. for part in parts[:-1]:
  278. cur = cur.setdefault(part, {})
  279. cur[parts[-1]] = value
  280. def _set_dotted_doc(doc, dotted: str, value) -> None:
  281. """Set a key in a tomlkit document, creating intermediate tables as needed."""
  282. parts = dotted.split(".")
  283. cur = doc
  284. for part in parts[:-1]:
  285. if part not in cur:
  286. cur[part] = tomlkit.table()
  287. cur = cur[part]
  288. cur[parts[-1]] = value
  289. def _migrate(data: dict) -> dict:
  290. """In-memory back-compat. Never rewrites the user's file on load.
  291. `quarantine.report_release_folder` (PoC) -> `[quarantine.report_release]` with the
  292. move+release pipeline. Two shapes of retired config are rewritten rather than
  293. rejected: a `delete` step (Report & Release no longer deletes — Delete is its own
  294. action) and a step list in the pre-flip order (release before move).
  295. """
  296. import pipeline # local import: pipeline imports nothing from us
  297. q = data.setdefault("quarantine", {})
  298. if "report_release" not in q:
  299. legacy = q.get("report_release_folder")
  300. q["report_release"] = {
  301. "steps": ["move", "release"] if legacy else ["release"],
  302. "move_target": legacy or "",
  303. }
  304. rr = q["report_release"]
  305. rr.setdefault("steps", ["move", "release"])
  306. steps = rr.get("steps")
  307. if isinstance(steps, (list, tuple)) and any(s in _RETIRED_STEPS for s in steps):
  308. steps = [s for s in steps if s not in _RETIRED_STEPS] or ["release"]
  309. log.warning("dropped retired Report & Release step(s) %s — Delete is a separate "
  310. "action; steps are now %s", sorted(_RETIRED_STEPS), steps)
  311. rr["steps"] = steps
  312. if (isinstance(steps, (list, tuple))
  313. and _is_subsequence(steps, _LEGACY_STEP_ORDER)
  314. and not _is_subsequence(steps, pipeline.ALLOWED_STEPS)):
  315. rr["steps"] = sorted(steps, key=pipeline.ALLOWED_STEPS.index)
  316. rr.setdefault("move_target", q.get("report_release_folder", "") or "")
  317. rr.setdefault("step_delay_seconds", 60)
  318. q.setdefault("default_sort_field", "subject")
  319. q.setdefault("default_sort_dir", "asc")
  320. a = data.setdefault("auth", {})
  321. # Fail closed: a config with no explicit mode defaults to oidc (which then requires
  322. # [okta] and fails loudly if missing), never to shared-password static admin.
  323. a.setdefault("mode", "oidc")
  324. a.setdefault("users", [])
  325. a.setdefault(
  326. "denied_message",
  327. "Your account is not authorised to use the PPS Quarantine Manager.",
  328. )
  329. return data
  330. def _validate(data: dict) -> None:
  331. """Validate the merged config. Raises ConfigError with per-field messages."""
  332. import pipeline # local import: pipeline imports nothing from us
  333. errors: list[dict[str, str]] = []
  334. def bad(field: str, msg: str) -> None:
  335. errors.append({"field": field, "message": msg})
  336. p = data.get("pps", {})
  337. url = str(p.get("base_url", ""))
  338. parsed = urlparse(url)
  339. if parsed.scheme not in ("http", "https") or not parsed.netloc:
  340. bad("pps.base_url", "must be an http(s) URL with a host")
  341. elif parsed.scheme == "http" and not os.environ.get("PPSQ_ALLOW_INSECURE"):
  342. bad("pps.base_url", "must use https (set PPSQ_ALLOW_INSECURE=1 to override)")
  343. try:
  344. t = int(p.get("timeout", 120))
  345. if not 5 <= t <= 600:
  346. bad("pps.timeout", "must be between 5 and 600 seconds")
  347. except (TypeError, ValueError):
  348. bad("pps.timeout", "must be an integer")
  349. for key in ("client_cert", "client_key"):
  350. val = p.get(key)
  351. if val and not Path(val).exists():
  352. bad(f"pps.{key}", f"file not found: {val}")
  353. q = data.get("quarantine", {})
  354. folders = q.get("folders", [])
  355. if not isinstance(folders, (list, tuple)) or not folders:
  356. bad("quarantine.folders", "at least one folder is required")
  357. folders = []
  358. else:
  359. seen = set()
  360. for f in folders:
  361. if not isinstance(f, str) or not f.strip():
  362. bad("quarantine.folders", "folder names must be non-empty strings")
  363. elif "," in f:
  364. # localguids are joined with "," in the POST payload (pps_client.act).
  365. bad("quarantine.folders", f"folder name may not contain a comma: {f!r}")
  366. elif f != f.strip():
  367. bad("quarantine.folders", f"folder name has leading/trailing space: {f!r}")
  368. elif len(f) > 128:
  369. bad("quarantine.folders", f"folder name too long: {f[:20]!r}…")
  370. elif f in seen:
  371. bad("quarantine.folders", f"duplicate folder: {f!r}")
  372. seen.add(f)
  373. for field in ("default_folder", "deleted_folder"):
  374. val = q.get(field)
  375. if folders and val and val not in folders:
  376. bad(f"quarantine.{field}", f"{val!r} is not in the folder list")
  377. try:
  378. lim = int(q.get("default_limit", 200))
  379. if not 1 <= lim <= 1000:
  380. bad("quarantine.default_limit", "must be between 1 and 1000")
  381. except (TypeError, ValueError):
  382. bad("quarantine.default_limit", "must be an integer")
  383. try:
  384. cs = int(q.get("chunk_size", 25))
  385. if not 1 <= cs <= 500:
  386. bad("quarantine.chunk_size", "must be between 1 and 500")
  387. except (TypeError, ValueError):
  388. bad("quarantine.chunk_size", "must be an integer")
  389. try:
  390. db = int(q.get("default_days_back", 7))
  391. if db < 1:
  392. bad("quarantine.default_days_back", "must be at least 1")
  393. except (TypeError, ValueError):
  394. bad("quarantine.default_days_back", "must be an integer")
  395. if not str(q.get("list_query", "")).strip():
  396. bad("quarantine.list_query", "required (the PPS API rejects folder-only searches)")
  397. if q.get("default_sort_field") not in _SORT_FIELDS:
  398. bad("quarantine.default_sort_field", f"must be one of {sorted(_SORT_FIELDS)}")
  399. if q.get("default_sort_dir") not in _SORT_DIRS:
  400. bad("quarantine.default_sort_dir", f"must be one of {sorted(_SORT_DIRS)}")
  401. rr = q.get("report_release", {})
  402. steps = rr.get("steps", [])
  403. if not isinstance(steps, (list, tuple)) or not steps:
  404. bad("quarantine.report_release.steps", "select at least one action")
  405. else:
  406. unknown = [s for s in steps if s not in pipeline.ALLOWED_STEPS]
  407. if unknown:
  408. bad("quarantine.report_release.steps", f"unknown step(s): {unknown}")
  409. elif len(set(steps)) != len(steps):
  410. bad("quarantine.report_release.steps", "duplicate steps")
  411. elif not _is_subsequence(steps, pipeline.ALLOWED_STEPS):
  412. bad(
  413. "quarantine.report_release.steps",
  414. f"must follow the fixed order {list(pipeline.ALLOWED_STEPS)}",
  415. )
  416. if "move" in steps:
  417. target = rr.get("move_target")
  418. if not target:
  419. bad("quarantine.report_release.move_target", "required when 'move' is selected")
  420. elif folders and target not in folders:
  421. bad(
  422. "quarantine.report_release.move_target",
  423. f"{target!r} is not in the folder list",
  424. )
  425. try:
  426. d = int(rr.get("step_delay_seconds", 60))
  427. if not 0 <= d <= 3600:
  428. bad("quarantine.report_release.step_delay_seconds", "must be between 0 and 3600")
  429. except (TypeError, ValueError):
  430. bad("quarantine.report_release.step_delay_seconds", "must be an integer")
  431. a = data.get("auth", {})
  432. if a.get("mode") not in ("oidc", "static"):
  433. bad("auth.mode", "must be 'oidc' or 'static'")
  434. users = a.get("users", [])
  435. if not isinstance(users, (list, tuple)):
  436. bad("auth.users", "must be a list")
  437. else:
  438. seen_emails = set()
  439. for i, u in enumerate(users):
  440. if not isinstance(u, Mapping):
  441. bad(f"auth.users[{i}]", "must be a table with email and role")
  442. continue
  443. email = str(u.get("email", "")).strip()
  444. if "@" not in email or " " in email or len(email) < 3:
  445. bad(f"auth.users[{i}].email", f"invalid email: {email!r}")
  446. elif email.casefold() in seen_emails:
  447. bad(f"auth.users[{i}].email", f"duplicate user: {email!r}")
  448. seen_emails.add(email.casefold())
  449. if u.get("role") not in _ROLES:
  450. bad(f"auth.users[{i}].role", f"must be one of {sorted(_ROLES)}")
  451. if errors:
  452. raise ConfigError(errors)
  453. def _is_subsequence(seq, universe) -> bool:
  454. it = iter(universe)
  455. return all(any(x == u for u in it) for x in seq)