"""Security settings: the `security` settings row laid over the environment defaults. The Admin Security tab edits the row. Every reader of these values (sessions, idle lock, roles refresh, sign-in rate limits) goes through `effective()` or `overlay()`. The stored row is cached in the process for at most CACHE_SECONDS; a save clears the cache at once and NOTIFYs `ps_settings` so the worker picks it up. A stored value out of its bounds (an older bound, a hand edit) is ignored and the default applies. """ from __future__ import annotations import json import logging import threading import time from collections import OrderedDict from dataclasses import dataclass, field from typing import Any from psycopg.types.json import Jsonb from .. import audit from ..settings import Settings log = logging.getLogger(__name__) KEY = "security" SETTINGS_CHANNEL = "ps_settings" CACHE_SECONDS = 30.0 # key: (min, max). The names are the Settings field names where one exists. BOUNDS: dict[str, tuple[int, int]] = { "session_idle_minutes": (1, 60), "session_lock_minutes": (1, 30), "session_desk_minutes": (5, 240), "session_password_hours": (1, 24), "session_pin_hours": (1, 24), "roles_refresh_minutes": (5, 60), "signin_account_limit": (1, 15), # always under AD's lockout at 20 "signin_account_window_minutes": (1, 60), "signin_account_cooldown_minutes": (0, 120), "signin_ip_limit": (5, 200), "signin_ip_window_minutes": (1, 60), "signin_burst_threshold": (5, 200), } KEYS = tuple(BOUNDS) # Keys that are not a Settings field: their default lives here. EXTRA_DEFAULTS: dict[str, int] = {"signin_burst_threshold": 20} BURST_WINDOW_MINUTES = 5 class SecSettingsError(ValueError): """Values the bounds refuse. `errors` holds one short line per problem.""" def __init__(self, errors: list[str]): super().__init__("; ".join(errors)) self.errors = errors class VersionConflict(Exception): def __init__(self, version: int): super().__init__(f"version is now {version}") self.version = version @dataclass(frozen=True) class SecSettings: """The effective values. Every key is an attribute; `settings` is the Settings copy with them applied.""" session_idle_minutes: int session_lock_minutes: int session_desk_minutes: int session_password_hours: int session_pin_hours: int roles_refresh_minutes: int signin_account_limit: int signin_account_window_minutes: int signin_account_cooldown_minutes: int signin_ip_limit: int signin_ip_window_minutes: int signin_burst_threshold: int version: int = 0 settings: Settings | None = field(default=None, compare=False, repr=False) def values(self) -> dict[str, int]: return {k: int(getattr(self, k)) for k in KEYS} def defaults(settings: Settings) -> dict[str, int]: """The environment defaults (Settings), plus the defaults of the keys Settings does not have.""" out: dict[str, int] = {} for k in KEYS: out[k] = int(getattr(settings, k)) if k in Settings.model_fields else EXTRA_DEFAULTS[k] return out def _in_bounds(key: str, value: Any) -> bool: if isinstance(value, bool) or not isinstance(value, int): return False lo, hi = BOUNDS[key] return lo <= value <= hi def stored_values(value: Any) -> dict[str, int]: """The good keys of a stored row value; anything unknown, not an integer, or out of bounds is dropped.""" if not isinstance(value, dict): return {} return {k: int(v) for k, v in value.items() if k in BOUNDS and _in_bounds(k, v)} # ---------------------------------------------------------------- the row and its cache _lock = threading.Lock() # (host, port, database) -> (loaded monotonic time, stored values, version) _rows: dict[tuple[str, int, str], tuple[float, dict[str, int], int]] = {} # (id(base settings), version, values) -> (base, overlaid Settings); keeps base alive so its id is not reused _overlays: OrderedDict[tuple[int, int, tuple[tuple[str, int], ...]], tuple[Settings, Settings]] = OrderedDict() OVERLAYS_MAX = 32 def _db_key(settings: Settings) -> tuple[str, int, str]: return (settings.pg_host, int(settings.pg_port), settings.pg_database) def clear_cache() -> None: with _lock: _rows.clear() _overlays.clear() def load_versioned(conn: Any) -> tuple[dict[str, int], int, Any, Any]: """(stored values, version, updated_at, updated_by) read now; version 0 when the row is missing.""" row = conn.execute("SELECT value, version, updated_at, updated_by FROM settings WHERE key = %s", (KEY,)).fetchone() if row is None: return {}, 0, None, None return stored_values(row[0]), int(row[1]), row[2], row[3] def _stored(conn: Any, settings: Settings) -> tuple[dict[str, int], int]: key = _db_key(settings) now = time.monotonic() with _lock: hit = _rows.get(key) if hit is not None and now - hit[0] < CACHE_SECONDS: return hit[1], hit[2] values, version, _, _ = load_versioned(conn) with _lock: _rows[key] = (now, values, version) return values, version def _is_overlay(settings: Settings) -> bool: with _lock: return any(v[1] is settings for v in _overlays.values()) def _overlay_settings(base: Settings, values: dict[str, int], version: int) -> Settings: upd = {k: v for k, v in values.items() if k in Settings.model_fields} if not upd: return base key = (id(base), version, tuple(sorted(upd.items()))) with _lock: hit = _overlays.get(key) if hit is not None and hit[0] is base: _overlays.move_to_end(key) return hit[1] out = base.model_copy(update=upd) with _lock: _overlays[key] = (base, out) while len(_overlays) > OVERLAYS_MAX: _overlays.popitem(last=False) return out def effective(conn: Any, settings: Settings) -> SecSettings: """The effective security settings: the stored row over the environment defaults.""" values, version = _stored(conn, settings) merged = {**defaults(settings), **values} over = settings if _is_overlay(settings) else _overlay_settings(settings, values, version) return SecSettings(**merged, version=version, settings=over) def overlay(conn: Any, settings: Settings) -> Settings: """`settings` with the stored security values applied. Passing an overlaid copy returns it as it is.""" if _is_overlay(settings): return settings values, version = _stored(conn, settings) return _overlay_settings(settings, values, version) def burst_threshold(conn: Any, settings: Settings) -> int: return effective(conn, settings).signin_burst_threshold # ---------------------------------------------------------------- saving def validate(values: Any) -> dict[str, int]: """A partial set of values, checked against the bounds. Raises SecSettingsError.""" if not isinstance(values, dict): raise SecSettingsError(["values must be an object"]) errors: list[str] = [] out: dict[str, int] = {} for k, v in values.items(): if k not in BOUNDS: errors.append(f"{k}: not a security setting") continue lo, hi = BOUNDS[k] if not _in_bounds(k, v): errors.append(f"{k}: a whole number from {lo} to {hi}") continue out[k] = int(v) if errors: raise SecSettingsError(errors) return out def save(conn: Any, settings: Settings, *, who: Any, values: Any, version: int) -> tuple[dict[str, int], int]: """Compare-and-set a partial change. Returns (stored values, new version). Raises SecSettingsError or VersionConflict. Audited `admin.security_settings_updated` with before and after (effective values).""" patch = validate(values) with conn.transaction(): row = conn.execute("SELECT value, version FROM settings WHERE key = %s FOR UPDATE", (KEY,)).fetchone() current = stored_values(row[0]) if row else {} cur_version = int(row[1]) if row else 0 if int(version) != cur_version: raise VersionConflict(cur_version) base = defaults(settings) before = {**base, **current} new = {**current, **patch} after = {**base, **new} changed = {k: {"from": before[k], "to": after[k]} for k in KEYS if before[k] != after[k]} new_version = conn.execute( "INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)" " ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1," " updated_at = now(), updated_by = EXCLUDED.updated_by RETURNING version", (KEY, Jsonb(new), who.user_id), ).fetchone()[0] audit.append( conn, action="admin.security_settings_updated", user_id=who.user_id, session_id=getattr(who, "session_id", None), role_snapshot=getattr(who, "role_snapshot", None), subject_type="settings", subject_id=KEY, detail={"before": before, "after": after, "changed": changed}, ip=getattr(who, "ip", None), ) payload = json.dumps({"key": KEY, "version": int(new_version)}, separators=(",", ":")) conn.execute("SELECT ps_notify(%s, %s)", (SETTINGS_CHANNEL, payload)) clear_cache() return new, int(new_version)