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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user