Files
PacketStation/server/packetstation/worker/jobs/ad_reconcile.py
T

134 lines
5.6 KiB
Python
Raw Normal View History

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