Phase 9: Admin completion, reconcile, notifications, integrity, backups
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

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:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+183 -91
View File
@@ -83,13 +83,43 @@ def get_user(conn: Any, user_id: Any) -> dict[str, Any] | None:
return row
def mark_inactive(conn: Any, user_id: Any, *, reason: str, gone: bool) -> None:
def end_sessions(conn: Any, user_id: Any, reason: str, *, by: str | None = None) -> list[uuid.UUID]:
"""End every live session of a user, one `auth.session_ended` audit row each. Returns the session ids."""
rows = conn.execute(
"SELECT id, roles_snapshot FROM sessions WHERE user_id = %s AND revoked_at IS NULL", (user_id,)
).fetchall()
ended: list[uuid.UUID] = []
for sid, snap in rows:
if not sessions.revoke(conn, sid, reason):
continue
ended.append(sid)
detail: dict[str, Any] = {"reason": reason}
if by:
detail["by"] = by
audit.append(
conn,
action="auth.session_ended",
user_id=user_id,
session_id=sid,
role_snapshot=",".join((snap or {}).get("roles", [])),
detail=detail,
)
return ended
def mark_inactive(conn: Any, user_id: Any, *, reason: str, gone: bool, audit_by: str | None = None) -> list[uuid.UUID]:
"""Mark a user inactive (disabled or gone in AD), drop the roles and end the sessions.
With `audit_by` every ended session gets an `auth.session_ended` row naming who ended it. Returns the ids.
"""
conn.execute(
"UPDATE users SET active = false, directory_flags = directory_flags || %s, ad_synced_at = now() WHERE id = %s",
(Jsonb({"disabled": not gone, "gone": gone}), user_id),
)
conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
sessions.revoke_user(conn, user_id, reason)
if audit_by:
return end_sessions(conn, user_id, reason, by=audit_by)
return sessions.revoke_user(conn, user_id, reason)
def public_user(row: dict[str, Any]) -> dict[str, Any]:
@@ -115,133 +145,195 @@ def ad_user_public(u: AdUser) -> dict[str, Any]:
}
# ---------------------------------------------------------------- AD refresh
# ---------------------------------------------------------------- AD reconcile
RECONCILE_BY = "ad-reconcile"
LIST_CAP = 200 # names kept in a report list; the count is always whole
class ReconcileRefused(Exception):
"""AD answered, but with something that would deactivate people by mistake. Nothing was changed."""
@dataclass
class RefreshResult:
groups_checked: int = 0
groups_missing: list[str] = field(default_factory=list)
groups_extra: list[str] = field(default_factory=list)
users_seen: int = 0
created: int = 0
updated: int = 0
deactivated: int = 0
sessions_ended: int = 0
emp_missing: list[str] = field(default_factory=list)
emp_duplicate: list[str] = field(default_factory=list)
created_names: list[str] = field(default_factory=list)
deactivated_names: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
"""The report: lists of names (missing and extra groups, accounts without an employee ID, duplicated
employee IDs) and counts. The same shape is the ad-reconcile run's counts."""
return {
"groups_checked": self.groups_checked,
"groups_missing": self.groups_missing,
"groups_missing": self.groups_missing[:LIST_CAP],
"groups_extra": self.groups_extra[:LIST_CAP],
"users_seen": self.users_seen,
"created": self.created,
"updated": self.updated,
"deactivated": self.deactivated,
"emp_missing": self.emp_missing,
"emp_duplicate": self.emp_duplicate,
"sessions_ended": self.sessions_ended,
"emp_missing": self.emp_missing[:LIST_CAP],
"emp_duplicate": self.emp_duplicate[:LIST_CAP],
}
def summary(self) -> dict[str, int]:
"""Counts only, for the audit row and the log."""
return {
"groups_checked": self.groups_checked,
"groups_missing": len(self.groups_missing),
"groups_extra": len(self.groups_extra),
"users_seen": self.users_seen,
"created": self.created,
"updated": self.updated,
"deactivated": self.deactivated,
"sessions_ended": self.sessions_ended,
"emp_missing": len(self.emp_missing),
"emp_duplicate": len(self.emp_duplicate),
}
def refresh_from_ad(
conn: Any, directory: DirectoryProtocol, *, actor: sessions.Actor | None = None, ip: str | None = None
) -> RefreshResult:
"""Enumerate every mapped group, upsert its members and rebuild their roles; deactivate gone accounts.
@dataclass
class _AdRead:
"""Everything the reconcile reads from AD, before any database write."""
All AD reads happen first, then the database writes in one transaction, so no lock is held while AD
answers. Raises LdapUnavailable when AD cannot be read.
"""
res = RefreshResult()
present: list[tuple[str, str]] # mapped groups found in the OU: (name as mapped, dn)
missing: list[str]
extra: list[str]
seen: dict[uuid.UUID, AdUser]
member_groups: dict[uuid.UUID, set[str]]
unseen: dict[uuid.UUID, AdUser | None]
def _read_ad(conn: Any, directory: DirectoryProtocol) -> _AdRead:
names = groupmap.group_names()
in_ad = {g.lower() for g in directory.list_groups()}
res.groups_missing = [n for n in names if n.lower() not in in_ad]
present = [n for n in names if n.lower() in in_ad]
in_ou = {cn.lower(): (cn, dn) for cn, dn in directory.list_groups_detail()}
mapped = {n.lower() for n in names}
present = [(n, in_ou[n.lower()][1]) for n in names if n.lower() in in_ou]
missing = [n for n in names if n.lower() not in in_ou]
extra = sorted((cn for key, (cn, _) in in_ou.items() if key not in mapped), key=str.lower)
if not present:
raise ReconcileRefused("no mapped group was found in the OU; check LDAP_GROUP_BASE_DN")
seen: dict[uuid.UUID, AdUser] = {}
member_groups: dict[uuid.UUID, set[str]] = {}
for name in present:
for u in directory.members_of(name):
for name, dn in present:
for u in directory.members_of_dn(dn):
seen.setdefault(u.guid, u)
member_groups.setdefault(u.guid, set()).add(name)
res.groups_checked = len(present)
res.users_seen = len(seen)
known = [r[0] for r in conn.execute("SELECT id FROM users WHERE active ORDER BY id").fetchall()]
unseen_ids = [uid for uid in known if uid not in seen]
if not seen and unseen_ids:
raise ReconcileRefused("AD listed no member in any mapped group; nobody was changed")
unseen = directory.users_by_guid(unseen_ids) if unseen_ids else {}
return _AdRead(present, missing, extra, seen, member_groups, unseen)
known = conn.execute("SELECT id FROM users WHERE active").fetchall()
unseen = [r[0] for r in known if r[0] not in seen]
# Accounts not in any group: still in AD (keep, no roles), disabled, or gone
unseen_state: dict[uuid.UUID, AdUser | None] = {uid: directory.get_user_by_guid(uid) for uid in unseen}
enabled = [u for u in seen.values() if not u.disabled]
def refresh_from_ad(
conn: Any,
directory: DirectoryProtocol,
*,
actor: sessions.Actor | None = None,
ip: str | None = None,
run_id: int | None = None,
) -> RefreshResult:
"""The AD reconcile. List the OU (missing and extra groups), enumerate the members of every mapped group
(IN_CHAIN, paged), upsert them on objectGUID and rebuild their roles. An active user AD no longer lists in
any group is looked up by GUID: gone or disabled means inactive with the sessions ended (`ad_gone`,
`ad_disabled`); still there means no roles and the sessions ended (`roles_lost`). directory_flags gets
emp_missing, emp_duplicate, gone and disabled.
Every AD read happens first, then the writes in one transaction, so no lock is held while AD answers.
Raises LdapUnavailable when AD cannot be read and ReconcileRefused when what it read looks wrong.
"""
res = RefreshResult()
ad = _read_ad(conn, directory)
res.groups_missing = ad.missing
res.groups_extra = ad.extra
res.groups_checked = len(ad.present)
res.users_seen = len(ad.seen)
enabled = [u for u in ad.seen.values() if not u.disabled]
enabled += [u for u in ad.unseen.values() if u is not None and not u.disabled]
res.emp_missing = sorted(u.sam_account for u in enabled if not u.emp_no)
counts = Counter(u.emp_no for u in enabled if u.emp_no)
res.emp_duplicate = sorted(e for e, n in counts.items() if n > 1)
dup = set(res.emp_duplicate)
who: dict[str, Any] = {
"user_id": actor.user_id if actor else None,
"session_id": actor.session_id if actor else None,
"role_snapshot": actor.role_snapshot if actor else None,
}
with conn.transaction():
for guid, u in seen.items():
grp = groupmap.mapped_groups(member_groups[guid])
was_active_row = conn.execute("SELECT active FROM users WHERE id = %s", (guid,)).fetchone()
created = upsert_user(conn, u, groups=grp)
conn.execute(
"UPDATE users SET directory_flags = directory_flags || %s WHERE id = %s",
(Jsonb({"emp_duplicate": bool(u.emp_no and u.emp_no in dup), "gone": False}), guid),
)
if created:
res.created += 1
else:
res.updated += 1
if u.disabled:
set_roles(conn, guid, [])
sessions.revoke_user(conn, guid, "ad_disabled")
if was_active_row is not None and was_active_row[0]:
res.deactivated += 1
audit.append(
conn,
action="ad.user_deactivated",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
subject_type="user",
subject_id=guid,
detail={"reason": "disabled", "sam_account": u.sam_account},
ip=ip,
)
else:
set_roles(conn, guid, groupmap.roles_from_groups(grp))
for uid, found in unseen_state.items():
if found is None or found.disabled:
mark_inactive(conn, uid, reason="ad_disabled" if found else "ad_gone", gone=found is None)
res.deactivated += 1
audit.append(
conn,
action="ad.user_deactivated",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
subject_type="user",
subject_id=uid,
detail={"reason": "gone" if found is None else "disabled"},
ip=ip,
)
else:
upsert_user(conn, found, groups=[])
set_roles(conn, uid, [])
sessions.revoke_user(conn, uid, "roles_lost")
def deactivated(uid: uuid.UUID, reason: str, sam: str) -> None:
res.deactivated += 1
res.deactivated_names.append(sam)
audit.append(
conn,
action="ad.refresh",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
detail={
"groups_checked": res.groups_checked,
"groups_missing": len(res.groups_missing),
"users_seen": res.users_seen,
"created": res.created,
"updated": res.updated,
"deactivated": res.deactivated,
"emp_missing": len(res.emp_missing),
"emp_duplicate": len(res.emp_duplicate),
},
action="ad.user_deactivated",
subject_type="user",
subject_id=uid,
detail={"reason": reason, "sam_account": sam},
ip=ip,
**who,
)
log.info("AD refresh: %s", {k: v for k, v in res.as_dict().items() if isinstance(v, int)})
def flags(uid: uuid.UUID, u: AdUser | None) -> None:
conn.execute(
"UPDATE users SET directory_flags = directory_flags || %s WHERE id = %s",
(Jsonb({"emp_duplicate": bool(u and u.emp_no and u.emp_no in dup), "gone": u is None}), uid),
)
with conn.transaction():
for guid, u in ad.seen.items():
grp = groupmap.mapped_groups(ad.member_groups[guid])
was = conn.execute("SELECT active FROM users WHERE id = %s", (guid,)).fetchone()
if upsert_user(conn, u, groups=grp):
res.created += 1
res.created_names.append(u.sam_account)
else:
res.updated += 1
flags(guid, u)
roles = [] if u.disabled else groupmap.roles_from_groups(grp)
set_roles(conn, guid, roles)
if u.disabled:
res.sessions_ended += len(end_sessions(conn, guid, "ad_disabled", by=RECONCILE_BY))
if was is not None and was[0]:
deactivated(guid, "disabled", u.sam_account)
elif not roles:
res.sessions_ended += len(end_sessions(conn, guid, "roles_lost", by=RECONCILE_BY))
for uid, found in ad.unseen.items():
if found is None or found.disabled:
row = conn.execute("SELECT sam_account FROM users WHERE id = %s", (uid,)).fetchone()
ended = mark_inactive(
conn,
uid,
reason="ad_gone" if found is None else "ad_disabled",
gone=found is None,
audit_by=RECONCILE_BY,
)
res.sessions_ended += len(ended)
flags(uid, found)
deactivated(uid, "gone" if found is None else "disabled", row[0] if row else str(uid))
else:
upsert_user(conn, found, groups=[])
flags(uid, found)
set_roles(conn, uid, [])
res.sessions_ended += len(end_sessions(conn, uid, "roles_lost", by=RECONCILE_BY))
detail: dict[str, Any] = dict(res.summary())
if run_id is not None:
detail["run_id"] = run_id
audit.append(conn, action="ad.refresh", detail=detail, ip=ip, **who)
log.info("AD reconcile: %s", res.summary())
return res
reconcile = refresh_from_ad
+77 -2
View File
@@ -32,6 +32,8 @@ USER_ATTRS = (
ACCOUNTDISABLE = 2
INVALID_CREDENTIALS = 49
PAGE_SIZE = 500
GUID_BATCH = 50 # objectGUID terms in one OR filter
SEARCH_GROUPS_MAX = 100
class LdapUnavailable(Exception):
@@ -60,6 +62,10 @@ class DirectoryProtocol(Protocol):
def groups_of(self, dn: str) -> list[str]: ...
def list_groups(self) -> list[str]: ...
def members_of(self, group_cn: str) -> list[AdUser]: ...
def list_groups_detail(self) -> list[tuple[str, str]]: ...
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]: ...
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]: ...
# ---------------------------------------------------------------- attribute helpers
@@ -287,6 +293,7 @@ class Directory:
return sorted({cn for e in found if (cn := _text(e.get("attributes") or {}, "cn"))})
def members_of(self, group_cn: str) -> list[AdUser]:
"""Every account in the group under LDAP_GROUP_BASE_DN, nested included (IN_CHAIN, paged)."""
from ldap3.utils.conv import escape_filter_chars
with self._service() as conn:
@@ -299,6 +306,74 @@ class Directory:
if not groups:
return []
gdn = _text(groups[0].get("attributes") or {}, "distinguishedName") or str(groups[0].get("dn") or "")
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(gdn)}))"
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
return self._members(conn, gdn)
def _members(self, conn: Any, group_dn: str) -> list[AdUser]:
from ldap3.utils.conv import escape_filter_chars
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(group_dn)}))"
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
return [user_from_entry(e) for e in found]
def list_groups_detail(self) -> list[tuple[str, str]]:
"""(cn, dn) of every group in LDAP_GROUP_BASE_DN, sorted by cn."""
with self._service() as conn:
found = self._search(
conn, self.settings.ldap_group_base_dn, "(objectClass=group)", ["cn", "distinguishedName"]
)
out: dict[str, tuple[str, str]] = {}
for e in found:
attrs = e.get("attributes") or {}
cn = _text(attrs, "cn")
if cn:
out[cn.lower()] = (cn, _text(attrs, "distinguishedName") or str(e.get("dn") or ""))
return sorted(out.values(), key=lambda t: t[0].lower())
def members_of_dn(self, group_dn: str) -> list[AdUser]:
"""Every account in the group by DN, nested included, disabled accounts too. Paged at 500."""
with self._service() as conn:
return self._members(conn, group_dn)
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]:
"""Groups anywhere under LDAP_BASE_DN whose name or description contains q: [{dn, name, description}]."""
from ldap3.utils.conv import escape_filter_chars
q = q.strip()
if not q:
return []
limit = max(1, min(int(limit), SEARCH_GROUPS_MAX))
term = escape_filter_chars(q)
flt = f"(&(objectClass=group)(|(cn=*{term}*)(description=*{term}*)))"
with self._service() as conn:
found = self._search(conn, self.settings.ldap_base_dn, flt, ["cn", "distinguishedName", "description"])
out = []
for e in found:
attrs = e.get("attributes") or {}
name = _text(attrs, "cn")
if not name:
continue
out.append(
{
"dn": _text(attrs, "distinguishedName") or str(e.get("dn") or ""),
"name": name,
"description": _text(attrs, "description") or "",
}
)
out.sort(key=lambda g: (not g["name"].lower().startswith(q.lower()), g["name"].lower()))
return out[:limit]
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
"""The accounts for these objectGUIDs, read in batches. None for a GUID AD no longer has."""
wanted = list(dict.fromkeys(guids))
out: dict[uuid.UUID, AdUser | None] = dict.fromkeys(wanted)
if not wanted:
return out
with self._service() as conn:
for i in range(0, len(wanted), GUID_BATCH):
part = wanted[i : i + GUID_BATCH]
flt = "(|" + "".join(guid_filter(g) for g in part) + ")"
for e in self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS):
u = user_from_entry(e)
if u.guid in out:
out[u.guid] = u
return out
+3
View File
@@ -101,6 +101,9 @@ def register_failure(
detail={"failures": int(row[0]), "where": where},
ip=ip,
)
from ..domain import security_alerts
security_alerts.pin_disabled(conn, user_id, int(row[0]), where)
return True
+5 -1
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta
from typing import Any
from ..settings import Settings
from . import secsettings
RATE_LIMITED = "rate_limited"
KINDS = ("password", "pin", "unlock", "signature", "pin_set")
@@ -62,7 +63,10 @@ def check(
ip: str | None,
now: datetime | None = None,
) -> int | None:
"""Seconds until another attempt is allowed, or None when allowed. Failures of all `accounts` count together."""
"""Seconds until another attempt is allowed, or None when allowed. Failures of all `accounts` count together.
The limits are the effective security settings (the Admin's values over the environment defaults)."""
settings = secsettings.overlay(conn, settings)
now = now or db_now(conn)
window = timedelta(minutes=settings.signin_account_window_minutes)
cooldown = timedelta(minutes=settings.signin_account_cooldown_minutes)
+252
View File
@@ -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)
+2
View File
@@ -14,6 +14,7 @@ from psycopg.types.json import Jsonb
from .. import events
from ..settings import Settings
from . import secsettings
SESSION_COOKIE = "ps_session"
DEVICE_COOKIE = "ps_device"
@@ -58,6 +59,7 @@ def create(
) -> tuple[str, dict[str, Any]]:
"""Insert a session row; returns (cookie token, row). The token itself is never stored."""
token = new_token()
settings = secsettings.overlay(conn, settings)
hours = settings.session_pin_hours if kind == "pin" else settings.session_password_hours
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
+12
View File
@@ -14,6 +14,7 @@ from psycopg.rows import dict_row
from .. import audit
from ..api.errors import ApiError
from ..domain import security_alerts
from ..settings import Settings
from . import devices, ratelimit, sessions
from . import directory as userdir
@@ -91,6 +92,7 @@ def rate_gate(
def _fail(
conn: Any,
*,
settings: Settings,
account: str,
ip: str | None,
kind: str,
@@ -111,6 +113,7 @@ def _fail(
detail={"reason": reason, "account": account, "kind": kind},
ip=ip,
)
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=user_id)
raise ApiError(status, code, detail)
@@ -187,6 +190,7 @@ def password_signin(
# Refused before LDAP: an empty password would be an anonymous bind
_fail(
conn,
settings=settings,
account=account or "(empty)",
ip=ip,
kind="password",
@@ -203,6 +207,7 @@ def password_signin(
if u is None:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -230,6 +235,7 @@ def password_signin(
if not ok:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -242,6 +248,7 @@ def password_signin(
if u.disabled:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -259,6 +266,7 @@ def password_signin(
if not roles:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -302,6 +310,7 @@ def pin_signin(
def fail(reason: str, user_id: Any = None, *, audited: bool = False) -> NoReturn:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="pin",
@@ -338,6 +347,7 @@ def pin_signin(
with conn.transaction():
ratelimit.record(conn, account=account, ip=ip, kind="pin", ok=False, reason="bad_pin", user_id=uid)
pins.register_failure(conn, settings, uid, ip=ip, where="signin")
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind="pin", user_id=uid)
raise ApiError(401, "signin_failed", PIN_FAILED)
try:
u = directory.get_user_by_guid(uid)
@@ -354,6 +364,7 @@ def pin_signin(
if not roles:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="pin",
@@ -423,6 +434,7 @@ def set_pin(
user_id=actor.user_id,
session_id=actor.session_id,
)
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind="pin_set", user_id=actor.user_id)
raise ApiError(403, "bad_password", "The password is wrong.")
with conn.transaction():
pins.set_pin(conn, actor.user_id, new_pin)
+3
View File
@@ -11,6 +11,7 @@ from typing import Any
from .. import audit
from ..api.errors import ApiError
from ..domain import security_alerts
from ..settings import Settings
from . import pin as pins
from . import ratelimit, sessions
@@ -121,8 +122,10 @@ def verify(
(actor.session_id,),
).fetchone()
failures = int(row[0]) if row else settings.stepup_max_failures
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=actor.user_id)
if failures >= settings.stepup_max_failures:
end_session(conn, actor, "stepup_failures", ip)
security_alerts.stepup_session_ended(conn, actor.user_id, failures, kind, ip)
raise ApiError(
401,
"session_ended",