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