2026-10-16 07:40:29 -06:00
|
|
|
"""The cached directory: `users` and `user_roles` rows written from what AD reports.
|
|
|
|
|
|
|
|
|
|
Packet Station grants no roles. `user_roles` is a cache of the roles AD groups give, rebuilt at every
|
|
|
|
|
sign-in, on the session's roles refresh and by the AD refresh.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import uuid
|
|
|
|
|
from collections import Counter
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from psycopg.rows import dict_row
|
|
|
|
|
from psycopg.types.json import Jsonb
|
|
|
|
|
|
|
|
|
|
from .. import audit
|
|
|
|
|
from . import groups as groupmap
|
|
|
|
|
from . import sessions
|
|
|
|
|
from .ldap import AdUser, DirectoryProtocol
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upsert_user(conn: Any, u: AdUser, *, groups: list[str], signin: bool = False) -> bool:
|
|
|
|
|
"""Insert or refresh the users row for an AD account. True when the row is new.
|
|
|
|
|
|
|
|
|
|
A different, older row holding the same sAMAccountName (a deleted and re-created AD account) is renamed
|
|
|
|
|
and marked inactive so the unique index holds and its history keeps its name.
|
|
|
|
|
"""
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE users SET sam_account = sam_account || '~' || left(id::text, 8), active = false"
|
|
|
|
|
" WHERE lower(sam_account) = lower(%s) AND id <> %s",
|
|
|
|
|
(u.sam_account, u.guid),
|
|
|
|
|
)
|
|
|
|
|
flags = {"disabled": u.disabled, "emp_missing": not u.emp_no, "groups": groups}
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"INSERT INTO users (id, sam_account, upn, display_name, emp_no, email, active, directory_flags,"
|
|
|
|
|
" ad_synced_at, last_signin_at)"
|
|
|
|
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now(), CASE WHEN %s THEN now() END)"
|
|
|
|
|
" ON CONFLICT (id) DO UPDATE SET sam_account = EXCLUDED.sam_account, upn = EXCLUDED.upn,"
|
|
|
|
|
" display_name = EXCLUDED.display_name, emp_no = EXCLUDED.emp_no, email = EXCLUDED.email,"
|
|
|
|
|
" active = EXCLUDED.active, directory_flags = users.directory_flags || EXCLUDED.directory_flags,"
|
|
|
|
|
" ad_synced_at = now(), last_signin_at = coalesce(EXCLUDED.last_signin_at, users.last_signin_at)"
|
|
|
|
|
" RETURNING (xmax = 0) AS inserted",
|
|
|
|
|
(
|
|
|
|
|
u.guid,
|
|
|
|
|
u.sam_account,
|
|
|
|
|
u.upn,
|
|
|
|
|
u.display_name,
|
|
|
|
|
u.emp_no,
|
|
|
|
|
u.email,
|
|
|
|
|
not u.disabled,
|
|
|
|
|
Jsonb(flags),
|
|
|
|
|
signin,
|
|
|
|
|
),
|
|
|
|
|
).fetchone()
|
|
|
|
|
return bool(row[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_roles(conn: Any, user_id: Any, roles: list[str]) -> None:
|
|
|
|
|
"""Rebuild a user's `user_roles` rows from role strings."""
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
|
|
|
|
|
for text in roles:
|
|
|
|
|
r = groupmap.parse_role(text)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO user_roles (user_id, role, dept_code) VALUES (%s, %s::role_kind, %s)",
|
|
|
|
|
(user_id, r.kind, r.dept_code),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def roles_of(conn: Any, user_id: Any) -> list[str]:
|
|
|
|
|
rows = conn.execute("SELECT role::text, dept_code FROM user_roles WHERE user_id = %s", (user_id,)).fetchall()
|
|
|
|
|
return groupmap.current().sort_roles(groupmap.role_from_row(r[0], r[1]) for r in rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_user(conn: Any, user_id: Any) -> dict[str, Any] | None:
|
|
|
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
|
|
|
row: dict[str, Any] | None = cur.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
def end_sessions(conn: Any, user_id: Any, reason: str, *, by: str | None = None) -> list[uuid.UUID]:
|
|
|
|
|
"""End every live session of a user, one `auth.session_ended` audit row each. Returns the session ids."""
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
"SELECT id, roles_snapshot FROM sessions WHERE user_id = %s AND revoked_at IS NULL", (user_id,)
|
|
|
|
|
).fetchall()
|
|
|
|
|
ended: list[uuid.UUID] = []
|
|
|
|
|
for sid, snap in rows:
|
|
|
|
|
if not sessions.revoke(conn, sid, reason):
|
|
|
|
|
continue
|
|
|
|
|
ended.append(sid)
|
|
|
|
|
detail: dict[str, Any] = {"reason": reason}
|
|
|
|
|
if by:
|
|
|
|
|
detail["by"] = by
|
|
|
|
|
audit.append(
|
|
|
|
|
conn,
|
|
|
|
|
action="auth.session_ended",
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
session_id=sid,
|
|
|
|
|
role_snapshot=",".join((snap or {}).get("roles", [])),
|
|
|
|
|
detail=detail,
|
|
|
|
|
)
|
|
|
|
|
return ended
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mark_inactive(conn: Any, user_id: Any, *, reason: str, gone: bool, audit_by: str | None = None) -> list[uuid.UUID]:
|
|
|
|
|
"""Mark a user inactive (disabled or gone in AD), drop the roles and end the sessions.
|
|
|
|
|
|
|
|
|
|
With `audit_by` every ended session gets an `auth.session_ended` row naming who ended it. Returns the ids.
|
|
|
|
|
"""
|
2026-10-16 07:40:29 -06:00
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE users SET active = false, directory_flags = directory_flags || %s, ad_synced_at = now() WHERE id = %s",
|
|
|
|
|
(Jsonb({"disabled": not gone, "gone": gone}), user_id),
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
|
2026-10-21 18:27:09 -06:00
|
|
|
if audit_by:
|
|
|
|
|
return end_sessions(conn, user_id, reason, by=audit_by)
|
|
|
|
|
return sessions.revoke_user(conn, user_id, reason)
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def public_user(row: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": str(row["id"]),
|
|
|
|
|
"sam_account": row["sam_account"],
|
|
|
|
|
"upn": row.get("upn"),
|
|
|
|
|
"display_name": row["display_name"],
|
|
|
|
|
"emp_no": row.get("emp_no"),
|
|
|
|
|
"email": row.get("email"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ad_user_public(u: AdUser) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": str(u.guid),
|
|
|
|
|
"dn": u.dn,
|
|
|
|
|
"sam_account": u.sam_account,
|
|
|
|
|
"upn": u.upn,
|
|
|
|
|
"display_name": u.display_name,
|
|
|
|
|
"emp_no": u.emp_no,
|
|
|
|
|
"email": u.email,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
# ---------------------------------------------------------------- AD reconcile
|
|
|
|
|
|
|
|
|
|
RECONCILE_BY = "ad-reconcile"
|
|
|
|
|
LIST_CAP = 200 # names kept in a report list; the count is always whole
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReconcileRefused(Exception):
|
|
|
|
|
"""AD answered, but with something that would deactivate people by mistake. Nothing was changed."""
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class RefreshResult:
|
|
|
|
|
groups_checked: int = 0
|
|
|
|
|
groups_missing: list[str] = field(default_factory=list)
|
2026-10-21 18:27:09 -06:00
|
|
|
groups_extra: list[str] = field(default_factory=list)
|
2026-10-16 07:40:29 -06:00
|
|
|
users_seen: int = 0
|
|
|
|
|
created: int = 0
|
|
|
|
|
updated: int = 0
|
|
|
|
|
deactivated: int = 0
|
2026-10-21 18:27:09 -06:00
|
|
|
sessions_ended: int = 0
|
2026-10-16 07:40:29 -06:00
|
|
|
emp_missing: list[str] = field(default_factory=list)
|
|
|
|
|
emp_duplicate: list[str] = field(default_factory=list)
|
2026-10-21 18:27:09 -06:00
|
|
|
created_names: list[str] = field(default_factory=list)
|
|
|
|
|
deactivated_names: list[str] = field(default_factory=list)
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
2026-10-21 18:27:09 -06:00
|
|
|
"""The report: lists of names (missing and extra groups, accounts without an employee ID, duplicated
|
|
|
|
|
employee IDs) and counts. The same shape is the ad-reconcile run's counts."""
|
2026-10-16 07:40:29 -06:00
|
|
|
return {
|
|
|
|
|
"groups_checked": self.groups_checked,
|
2026-10-21 18:27:09 -06:00
|
|
|
"groups_missing": self.groups_missing[:LIST_CAP],
|
|
|
|
|
"groups_extra": self.groups_extra[:LIST_CAP],
|
2026-10-16 07:40:29 -06:00
|
|
|
"users_seen": self.users_seen,
|
|
|
|
|
"created": self.created,
|
|
|
|
|
"updated": self.updated,
|
|
|
|
|
"deactivated": self.deactivated,
|
2026-10-21 18:27:09 -06:00
|
|
|
"sessions_ended": self.sessions_ended,
|
|
|
|
|
"emp_missing": self.emp_missing[:LIST_CAP],
|
|
|
|
|
"emp_duplicate": self.emp_duplicate[:LIST_CAP],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def summary(self) -> dict[str, int]:
|
|
|
|
|
"""Counts only, for the audit row and the log."""
|
|
|
|
|
return {
|
|
|
|
|
"groups_checked": self.groups_checked,
|
|
|
|
|
"groups_missing": len(self.groups_missing),
|
|
|
|
|
"groups_extra": len(self.groups_extra),
|
|
|
|
|
"users_seen": self.users_seen,
|
|
|
|
|
"created": self.created,
|
|
|
|
|
"updated": self.updated,
|
|
|
|
|
"deactivated": self.deactivated,
|
|
|
|
|
"sessions_ended": self.sessions_ended,
|
|
|
|
|
"emp_missing": len(self.emp_missing),
|
|
|
|
|
"emp_duplicate": len(self.emp_duplicate),
|
2026-10-16 07:40:29 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
@dataclass
|
|
|
|
|
class _AdRead:
|
|
|
|
|
"""Everything the reconcile reads from AD, before any database write."""
|
2026-10-16 07:40:29 -06:00
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
present: list[tuple[str, str]] # mapped groups found in the OU: (name as mapped, dn)
|
|
|
|
|
missing: list[str]
|
|
|
|
|
extra: list[str]
|
|
|
|
|
seen: dict[uuid.UUID, AdUser]
|
|
|
|
|
member_groups: dict[uuid.UUID, set[str]]
|
|
|
|
|
unseen: dict[uuid.UUID, AdUser | None]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_ad(conn: Any, directory: DirectoryProtocol) -> _AdRead:
|
2026-10-16 07:40:29 -06:00
|
|
|
names = groupmap.group_names()
|
2026-10-21 18:27:09 -06:00
|
|
|
in_ou = {cn.lower(): (cn, dn) for cn, dn in directory.list_groups_detail()}
|
|
|
|
|
mapped = {n.lower() for n in names}
|
|
|
|
|
present = [(n, in_ou[n.lower()][1]) for n in names if n.lower() in in_ou]
|
|
|
|
|
missing = [n for n in names if n.lower() not in in_ou]
|
|
|
|
|
extra = sorted((cn for key, (cn, _) in in_ou.items() if key not in mapped), key=str.lower)
|
|
|
|
|
if not present:
|
|
|
|
|
raise ReconcileRefused("no mapped group was found in the OU; check LDAP_GROUP_BASE_DN")
|
2026-10-16 07:40:29 -06:00
|
|
|
seen: dict[uuid.UUID, AdUser] = {}
|
|
|
|
|
member_groups: dict[uuid.UUID, set[str]] = {}
|
2026-10-21 18:27:09 -06:00
|
|
|
for name, dn in present:
|
|
|
|
|
for u in directory.members_of_dn(dn):
|
2026-10-16 07:40:29 -06:00
|
|
|
seen.setdefault(u.guid, u)
|
|
|
|
|
member_groups.setdefault(u.guid, set()).add(name)
|
2026-10-21 18:27:09 -06:00
|
|
|
known = [r[0] for r in conn.execute("SELECT id FROM users WHERE active ORDER BY id").fetchall()]
|
|
|
|
|
unseen_ids = [uid for uid in known if uid not in seen]
|
|
|
|
|
if not seen and unseen_ids:
|
|
|
|
|
raise ReconcileRefused("AD listed no member in any mapped group; nobody was changed")
|
|
|
|
|
unseen = directory.users_by_guid(unseen_ids) if unseen_ids else {}
|
|
|
|
|
return _AdRead(present, missing, extra, seen, member_groups, unseen)
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
def refresh_from_ad(
|
|
|
|
|
conn: Any,
|
|
|
|
|
directory: DirectoryProtocol,
|
|
|
|
|
*,
|
|
|
|
|
actor: sessions.Actor | None = None,
|
|
|
|
|
ip: str | None = None,
|
|
|
|
|
run_id: int | None = None,
|
|
|
|
|
) -> RefreshResult:
|
|
|
|
|
"""The AD reconcile. List the OU (missing and extra groups), enumerate the members of every mapped group
|
|
|
|
|
(IN_CHAIN, paged), upsert them on objectGUID and rebuild their roles. An active user AD no longer lists in
|
|
|
|
|
any group is looked up by GUID: gone or disabled means inactive with the sessions ended (`ad_gone`,
|
|
|
|
|
`ad_disabled`); still there means no roles and the sessions ended (`roles_lost`). directory_flags gets
|
|
|
|
|
emp_missing, emp_duplicate, gone and disabled.
|
|
|
|
|
|
|
|
|
|
Every AD read happens first, then the writes in one transaction, so no lock is held while AD answers.
|
|
|
|
|
Raises LdapUnavailable when AD cannot be read and ReconcileRefused when what it read looks wrong.
|
|
|
|
|
"""
|
|
|
|
|
res = RefreshResult()
|
|
|
|
|
ad = _read_ad(conn, directory)
|
|
|
|
|
res.groups_missing = ad.missing
|
|
|
|
|
res.groups_extra = ad.extra
|
|
|
|
|
res.groups_checked = len(ad.present)
|
|
|
|
|
res.users_seen = len(ad.seen)
|
|
|
|
|
|
|
|
|
|
enabled = [u for u in ad.seen.values() if not u.disabled]
|
|
|
|
|
enabled += [u for u in ad.unseen.values() if u is not None and not u.disabled]
|
2026-10-16 07:40:29 -06:00
|
|
|
res.emp_missing = sorted(u.sam_account for u in enabled if not u.emp_no)
|
|
|
|
|
counts = Counter(u.emp_no for u in enabled if u.emp_no)
|
|
|
|
|
res.emp_duplicate = sorted(e for e, n in counts.items() if n > 1)
|
|
|
|
|
dup = set(res.emp_duplicate)
|
2026-10-21 18:27:09 -06:00
|
|
|
who: dict[str, Any] = {
|
|
|
|
|
"user_id": actor.user_id if actor else None,
|
|
|
|
|
"session_id": actor.session_id if actor else None,
|
|
|
|
|
"role_snapshot": actor.role_snapshot if actor else None,
|
|
|
|
|
}
|
2026-10-16 07:40:29 -06:00
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
def deactivated(uid: uuid.UUID, reason: str, sam: str) -> None:
|
|
|
|
|
res.deactivated += 1
|
|
|
|
|
res.deactivated_names.append(sam)
|
2026-10-16 07:40:29 -06:00
|
|
|
audit.append(
|
|
|
|
|
conn,
|
2026-10-21 18:27:09 -06:00
|
|
|
action="ad.user_deactivated",
|
|
|
|
|
subject_type="user",
|
|
|
|
|
subject_id=uid,
|
|
|
|
|
detail={"reason": reason, "sam_account": sam},
|
2026-10-16 07:40:29 -06:00
|
|
|
ip=ip,
|
2026-10-21 18:27:09 -06:00
|
|
|
**who,
|
2026-10-16 07:40:29 -06:00
|
|
|
)
|
2026-10-21 18:27:09 -06:00
|
|
|
|
|
|
|
|
def flags(uid: uuid.UUID, u: AdUser | None) -> None:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE users SET directory_flags = directory_flags || %s WHERE id = %s",
|
|
|
|
|
(Jsonb({"emp_duplicate": bool(u and u.emp_no and u.emp_no in dup), "gone": u is None}), uid),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with conn.transaction():
|
|
|
|
|
for guid, u in ad.seen.items():
|
|
|
|
|
grp = groupmap.mapped_groups(ad.member_groups[guid])
|
|
|
|
|
was = conn.execute("SELECT active FROM users WHERE id = %s", (guid,)).fetchone()
|
|
|
|
|
if upsert_user(conn, u, groups=grp):
|
|
|
|
|
res.created += 1
|
|
|
|
|
res.created_names.append(u.sam_account)
|
|
|
|
|
else:
|
|
|
|
|
res.updated += 1
|
|
|
|
|
flags(guid, u)
|
|
|
|
|
roles = [] if u.disabled else groupmap.roles_from_groups(grp)
|
|
|
|
|
set_roles(conn, guid, roles)
|
|
|
|
|
if u.disabled:
|
|
|
|
|
res.sessions_ended += len(end_sessions(conn, guid, "ad_disabled", by=RECONCILE_BY))
|
|
|
|
|
if was is not None and was[0]:
|
|
|
|
|
deactivated(guid, "disabled", u.sam_account)
|
|
|
|
|
elif not roles:
|
|
|
|
|
res.sessions_ended += len(end_sessions(conn, guid, "roles_lost", by=RECONCILE_BY))
|
|
|
|
|
for uid, found in ad.unseen.items():
|
|
|
|
|
if found is None or found.disabled:
|
|
|
|
|
row = conn.execute("SELECT sam_account FROM users WHERE id = %s", (uid,)).fetchone()
|
|
|
|
|
ended = mark_inactive(
|
|
|
|
|
conn,
|
|
|
|
|
uid,
|
|
|
|
|
reason="ad_gone" if found is None else "ad_disabled",
|
|
|
|
|
gone=found is None,
|
|
|
|
|
audit_by=RECONCILE_BY,
|
|
|
|
|
)
|
|
|
|
|
res.sessions_ended += len(ended)
|
|
|
|
|
flags(uid, found)
|
|
|
|
|
deactivated(uid, "gone" if found is None else "disabled", row[0] if row else str(uid))
|
|
|
|
|
else:
|
|
|
|
|
upsert_user(conn, found, groups=[])
|
|
|
|
|
flags(uid, found)
|
|
|
|
|
set_roles(conn, uid, [])
|
|
|
|
|
res.sessions_ended += len(end_sessions(conn, uid, "roles_lost", by=RECONCILE_BY))
|
|
|
|
|
detail: dict[str, Any] = dict(res.summary())
|
|
|
|
|
if run_id is not None:
|
|
|
|
|
detail["run_id"] = run_id
|
|
|
|
|
audit.append(conn, action="ad.refresh", detail=detail, ip=ip, **who)
|
|
|
|
|
log.info("AD reconcile: %s", res.summary())
|
2026-10-16 07:40:29 -06:00
|
|
|
return res
|
2026-10-21 18:27:09 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
reconcile = refresh_from_ad
|