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
+50
View File
@@ -10,6 +10,9 @@ from packetstation.auth.ldap import AdUser, LdapUnavailable
FAKE_PASSWORD = "fake-password-1"
NESTED_GROUP = "Shop-Welders-Nested"
BASE_DN = "DC=pstest,DC=local"
OU_DN = "OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON," + BASE_DN
OTHER_DN = "OU=Groups,OU=PESCO_FARMINGTON," + BASE_DN
@dataclass
@@ -62,12 +65,19 @@ class FakeDirectory:
users: list[FakeUser] | None = None,
ou_groups: list[str] | None = None,
nested: dict[str, list[str]] | None = None,
other_groups: dict[str, str] | None = None,
):
self.users = users if users is not None else default_users()
if ou_groups is None:
ou_groups = [g for g in group_names() if g != "PS-14-Electrical-Lead"] + ["PS-Extra-Legacy"]
self.ou_groups = ou_groups
self.nested = nested if nested is not None else {NESTED_GROUP: ["PS-10-WeldAssembly-Worker"]}
# Groups outside the OU, by name, with a description (the nested helper group and a mail group)
self.other_groups = (
other_groups
if other_groups is not None
else {NESTED_GROUP: "Helper group for the nested membership test", "IT-Helpdesk": "IT help desk"}
)
self.down = False
self.binds = 0
self.searches = 0
@@ -96,6 +106,18 @@ class FakeDirectory:
todo.extend(self.nested.get(g, []))
return seen
def group_dn(self, name: str) -> str:
"""The DN of a group: in the PacketStation OU, or under Groups for the ones outside it."""
if any(g.lower() == name.lower() for g in self.ou_groups):
return f"CN={name},{OU_DN}"
return f"CN={name},{OTHER_DN}"
def _group_by_dn(self, dn: str) -> str | None:
for name in [*self.ou_groups, *self.other_groups]:
if self.group_dn(name).lower() == dn.strip().lower():
return name
return None
def _in_ou(self, names: set[str]) -> list[str]:
return sorted(g for g in self.ou_groups if g.lower() in names)
@@ -138,3 +160,31 @@ class FakeDirectory:
if group_cn.lower() not in {g.lower() for g in self.ou_groups}:
return []
return [self._ad(u) for u in self.users if group_cn.lower() in self._closure(u)]
def list_groups_detail(self) -> list[tuple[str, str]]:
self._check()
return sorted(((g, self.group_dn(g)) for g in self.ou_groups), key=lambda t: t[0].lower())
def members_of_dn(self, group_dn: str) -> list[AdUser]:
self._check()
name = self._group_by_dn(group_dn)
if name is None:
return []
return [self._ad(u) for u in self.users if name.lower() in self._closure(u)]
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]:
self._check()
key = q.strip().lower()
if not key:
return []
out = []
for name in sorted({*self.ou_groups, *self.other_groups}, key=str.lower):
desc = self.other_groups.get(name, f"Packet Station: {name}")
if key in name.lower() or key in desc.lower():
out.append({"dn": self.group_dn(name), "name": name, "description": desc})
return out[: max(1, limit)]
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
self._check()
by = {u.guid: u for u in self.users}
return {g: (self._ad(by[g]) if g in by else None) for g in guids}