Files
spencermandClaude Opus 5.5 a82e056b6a
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
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]>
2026-10-21 18:27:09 -06:00

134 lines
5.6 KiB
Python

"""The AD reconcile, nightly at 01:30 site time and on request ("Refresh from AD now" on the Admin screen).
It reloads the departments, then runs `auth.directory.refresh_from_ad`: list the PacketStation OU, enumerate the
members of every mapped group (IN_CHAIN, paged at 500), upsert users on objectGUID, mark disabled or gone users
inactive and end their sessions, end the sessions of users left with no role, and flag missing or duplicate
employee IDs. The run's counts are the report the Admin screen shows.
Then the notifications: one `group_missing` condition per mapped group not in the OU (resolved once the group is
there), and one `ad_reconcile_report` when the run created or deactivated anyone, found extra groups in the OU or
flagged employee IDs. AD unreachable fails the run and changes nothing.
"""
from __future__ import annotations
from datetime import datetime, time
from typing import Any
from ...auth import directory as userdir
from ...auth import groups as groupmap
from ...feed.config import FeedSettings
from .. import jobs
from ..redaction import redact
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(1, 30)
NAME = "ad-reconcile"
FAILED_SUBJECT = "ad-reconcile:failed"
BODY_NAMES = 50 # names listed per section in the report mail
def _names(items: list[str]) -> str:
shown = ", ".join(items[:BODY_NAMES])
more = len(items) - BODY_NAMES
return shown + (f" and {more} more" if more > 0 else "")
def report_text(res: userdir.RefreshResult) -> tuple[str, str] | None:
"""(title, body) of the reconcile report, or None when there is nothing to report."""
parts: list[str] = []
lines: list[str] = []
if res.created:
parts.append(f"{res.created} added")
lines.append(f"Added: {_names(res.created_names)}.")
if res.deactivated:
parts.append(f"{res.deactivated} deactivated")
lines.append(f"Deactivated (disabled or gone in AD): {_names(res.deactivated_names)}.")
if res.groups_extra:
parts.append(f"{len(res.groups_extra)} extra group{'s' if len(res.groups_extra) != 1 else ''}")
lines.append(f"Groups in the OU that Packet Station does not map: {_names(res.groups_extra)}.")
if res.emp_missing:
parts.append(f"{len(res.emp_missing)} without an employee ID")
lines.append(f"Accounts without an employee ID: {_names(res.emp_missing)}.")
if res.emp_duplicate:
parts.append(f"{len(res.emp_duplicate)} duplicate employee ID{'s' if len(res.emp_duplicate) != 1 else ''}")
lines.append(f"Employee IDs on more than one account: {_names(res.emp_duplicate)}.")
if not parts:
return None
if res.sessions_ended:
lines.append(f"Sessions ended: {res.sessions_ended}.")
lines.append(f"Accounts seen in the mapped groups: {res.users_seen}.")
return "AD reconcile: " + ", ".join(parts), "\n".join(lines)
def notify(conn: Any, res: userdir.RefreshResult) -> dict[str, int]:
"""Raise and resolve the reconcile's events in one transaction. Returns what was raised."""
raised = {"group_missing": 0, "report": 0}
missing = {n.lower() for n in res.groups_missing}
with conn.transaction():
for name in res.groups_missing:
jobs.raise_event(
conn,
"group_missing",
subject=name,
title=f"Group missing from the OU: {name}",
body=(f"The AD group {name} is not in the PacketStation OU. Nobody gets its role until IT creates it."),
detail={"group": name},
condition=True,
)
raised["group_missing"] += 1
for name in groupmap.group_names():
if name.lower() not in missing:
jobs.resolve_event(conn, "group_missing", subject=name, title=f"Group missing from the OU: {name}")
text = report_text(res)
if text is not None:
jobs.raise_event(
conn,
"ad_reconcile_report",
subject=NAME,
title=text[0],
body=text[1],
detail=res.summary(),
severity="info",
)
raised["report"] = 1
return raised
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
jobs.reload_departments(conn)
try:
res = userdir.refresh_from_ad(conn, jobs.directory(ctx), run_id=ctx.run_id)
except Exception as e:
# AD down or the run refused: tell the report's recipients once, until a run succeeds again
try:
with conn.transaction():
jobs.raise_event(
conn,
"ad_reconcile_report",
subject=FAILED_SUBJECT,
title="AD reconcile did not run",
body=f"The nightly AD reconcile stopped and changed nothing: {type(e).__name__}: {redact(str(e))}",
condition=True,
)
except Exception:
ctx.log.exception("reconcile failure notice not raised")
raise
counts: Counts = res.as_dict()
try:
with conn.transaction():
jobs.resolve_event(conn, "ad_reconcile_report", subject=FAILED_SUBJECT, title="AD reconcile did not run")
notify(conn, res)
except Exception as e: # a notification problem never undoes the reconcile
ctx.log.exception("reconcile notifications failed")
ctx.error(f"notifications: {type(e).__name__}: {e}", degrade=False)
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)