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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user