253 lines
9.3 KiB
Python
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)
|