"""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)