Files
PacketStation/server/packetstation/auth/secsettings.py
T
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00

253 lines
9.3 KiB
Python

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