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]>
134 lines
5.6 KiB
Python
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)
|