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
+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