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
+222
View File
@@ -0,0 +1,222 @@
"""Rules and recipients. Recipients are resolved when a message goes, so a group stays current.
group: the members of an AD group by DN (nested, enabled accounts with mail). user: users.email of an active user.
mailbox: the address. dept_supervisor: the Supervisor group of the event's department, none without one.
Exclusions drop a person by user id and by that person's email.
"""
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import time
from typing import Any, Protocol
from psycopg.rows import dict_row
from ..auth import groups as groupmap
from ..auth.ldap import AdUser
from ..settings import Settings
from .catalog import EVENT_TYPES
log = logging.getLogger(__name__)
ADDRESS_RE = re.compile(r"^[^@\s<>,;\"']{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251})\.[A-Za-z]{2,63}$")
DIRECTORY_DOWN = "directory unavailable"
class NotifyDirectory(Protocol):
"""The directory calls notifications use (a subset of auth.ldap.DirectoryProtocol)."""
def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None: ...
def groups_of(self, dn: str) -> list[str]: ...
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, Any]]: ...
class DirectoryDown(Exception):
"""AD could not be read while resolving a group."""
def address_ok(value: str) -> bool:
v = value.strip()
return len(v) <= 254 and ADDRESS_RE.match(v) is not None
def cn_of(dn: str) -> str:
"""The first CN of a DN ("CN=PS-Admins,OU=..." -> "PS-Admins"); the DN itself when it has none."""
first = dn.split(",", 1)[0].strip()
return first[3:].strip() if first[:3].lower() == "cn=" else dn
def dn_under(dn: str, base: str) -> bool:
b = base.strip().lower().replace(" ", "")
return bool(b) and dn.strip().lower().replace(" ", "").endswith("," + b)
def supervisor_group(dept_code: str | None) -> str | None:
"""The Supervisor group name of a department, from the group map (None when it has none)."""
if not dept_code:
return None
for name, role in groupmap.current().entries:
if role.dept_code == dept_code and role.kind.endswith("supervisor"):
return name
return None
def group_dn(settings: Settings, name: str) -> str:
base = settings.ldap_group_base_dn.strip()
return f"CN={name},{base}" if base else f"CN={name}"
def supervisor_depts(roles: list[str]) -> list[str]:
"""The departments a role set supervises (their Supervisor group), sorted."""
out: set[str] = set()
for text in roles:
try:
r = groupmap.parse_role(text)
except ValueError:
continue
if r.dept_code and r.kind.endswith("supervisor"):
out.add(r.dept_code)
return sorted(out)
# ---------------------------------------------------------------- rules
@dataclass
class Rule:
event_type: str
enabled: bool
delivery: str
digest_time: time
param: int | None
version: int
recipients: list[dict[str, Any]] = field(default_factory=list)
exclusions: list[dict[str, Any]] = field(default_factory=list)
@property
def param_value(self) -> int | None:
et = EVENT_TYPES.get(self.event_type)
if et is None or et.param is None:
return None
return self.param if self.param is not None else et.param_default
def load_rules(conn: Any, event_type: str | None = None) -> dict[str, Rule]:
args = (event_type, event_type)
with conn.cursor(row_factory=dict_row) as cur:
rules = {
r["event_type"]: Rule(
r["event_type"],
bool(r["enabled"]),
str(r["delivery"]),
r["digest_time"],
r["param"],
int(r["version"]),
)
for r in cur.execute(
"SELECT event_type, enabled, delivery::text AS delivery, digest_time, param, version"
" FROM notification_rules WHERE %s::text IS NULL OR event_type = %s",
args,
).fetchall()
}
for r in cur.execute(
"SELECT r.id, r.event_type, r.kind::text AS kind, r.group_dn, r.group_name, r.user_id, r.address,"
" u.display_name FROM notification_recipients r LEFT JOIN users u ON u.id = r.user_id"
" WHERE %s::text IS NULL OR r.event_type = %s ORDER BY r.id",
args,
).fetchall():
if r["event_type"] in rules:
rules[r["event_type"]].recipients.append(dict(r))
for r in cur.execute(
"SELECT x.event_type, x.user_id, x.self, u.display_name, u.email FROM notification_exclusions x"
" LEFT JOIN users u ON u.id = x.user_id WHERE %s::text IS NULL OR x.event_type = %s"
" ORDER BY lower(u.display_name), x.user_id",
args,
).fetchall():
if r["event_type"] in rules:
rules[r["event_type"]].exclusions.append(dict(r))
return rules
def load_rule(conn: Any, event_type: str) -> Rule | None:
return load_rules(conn, event_type).get(event_type)
# ---------------------------------------------------------------- resolving
@dataclass
class Recipient:
address: str
user_id: str | None
why: str
def as_json(self) -> dict[str, Any]:
return {"address": self.address, "user_id": self.user_id, "why": self.why}
class Resolver:
"""Resolves rules to addresses. Caches group members for its lifetime (one run or one request)."""
def __init__(self, conn: Any, settings: Settings, directory: NotifyDirectory | None):
self.conn = conn
self.settings = settings
self.directory = directory
self._members: dict[str, list[AdUser]] = {}
def members(self, dn: str) -> list[AdUser]:
key = dn.strip().lower()
if key not in self._members:
if self.directory is None:
raise DirectoryDown(DIRECTORY_DOWN)
try:
self._members[key] = list(self.directory.members_of_dn(dn))
except Exception as e:
log.warning("notify: can't read group %s: %s", cn_of(dn), type(e).__name__)
raise DirectoryDown(DIRECTORY_DOWN) from None
return self._members[key]
def _group(self, dn: str, why: str) -> list[Recipient]:
return [
Recipient(u.email.strip(), str(u.guid), why)
for u in self.members(dn)
if not u.disabled and u.email and u.email.strip()
]
def resolve(self, rule: Rule, dept_code: str | None) -> list[Recipient]:
"""Every address the rule reaches for an event of this department, after exclusions, one per address.
Raises DirectoryDown when a group cannot be read."""
found: list[Recipient] = []
for r in rule.recipients:
kind = r["kind"]
if kind == "group" and r.get("group_dn"):
found.extend(self._group(r["group_dn"], f"group {r.get('group_name') or cn_of(r['group_dn'])}"))
elif kind == "user" and r.get("user_id"):
row = self.conn.execute(
"SELECT email FROM users WHERE id = %s AND active AND coalesce(email, '') <> ''", (r["user_id"],)
).fetchone()
if row is not None:
found.append(Recipient(str(row[0]).strip(), str(r["user_id"]), "user"))
elif kind == "mailbox" and r.get("address"):
found.append(Recipient(str(r["address"]).strip(), None, "mailbox"))
elif kind == "dept_supervisor":
name = supervisor_group(dept_code)
if name:
found.extend(self._group(group_dn(self.settings, name), f"Supervisor group of {dept_code}"))
excluded_ids = {str(x["user_id"]) for x in rule.exclusions}
excluded_mail = {str(x["email"]).strip().lower() for x in rule.exclusions if x.get("email")}
out: list[Recipient] = []
seen: set[str] = set()
for rc in found:
key = rc.address.lower()
if key in seen or (rc.user_id and rc.user_id in excluded_ids) or key in excluded_mail:
continue
seen.add(key)
out.append(rc)
return out