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]>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user