"""Session refresh, every `roles_refresh_minutes` (the effective security setting, 15 by default). For every user with a live session: read the account from AD by objectGUID (batched), re-resolve its groups (nested) and userAccountControl, rebuild `user_roles` and each live session's roles snapshot and roles_checked_at. A user disabled or gone in AD is marked inactive and their sessions end (`ad_disabled`, `ad_gone`); a user left with no role has their sessions ended (`roles_lost`). Every AD read happens before any write: AD unreachable makes the run degraded and nothing is ended. """ from __future__ import annotations import threading import uuid from datetime import datetime from typing import Any from psycopg.types.json import Jsonb from ... import audit from ...auth import directory as userdir from ...auth import groups as groupmap from ...auth.ldap import AdUser, LdapUnavailable from ...feed.config import FeedSettings from ...settings import Settings from .. import jobs from . import Counts, JobContext, JobSpec NAME = "session-refresh" DEFAULT_MINUTES = 15 _lock = threading.Lock() _minutes = DEFAULT_MINUTES def refresh_minutes(conn: Any, settings: Settings) -> int: """The effective roles_refresh_minutes: the Admin's security setting when that module is there, else Settings.""" try: from ...auth import secsettings except ImportError: return int(settings.roles_refresh_minutes) try: return int(secsettings.effective(conn, settings).roles_refresh_minutes) except Exception: return int(settings.roles_refresh_minutes) def _set_minutes(value: int) -> None: global _minutes with _lock: _minutes = max(1, int(value)) def live_users(conn: Any) -> list[uuid.UUID]: rows = conn.execute( "SELECT DISTINCT user_id FROM sessions WHERE revoked_at IS NULL AND expires_at > now() ORDER BY user_id" ).fetchall() return [r[0] for r in rows] def _deactivate(conn: Any, uid: uuid.UUID, found: AdUser | None) -> int: gone = found is None row = conn.execute("SELECT sam_account, active FROM users WHERE id = %s", (uid,)).fetchone() ended = userdir.mark_inactive(conn, uid, reason="ad_gone" if gone else "ad_disabled", gone=gone, audit_by=NAME) if row is not None and row[1]: audit.append( conn, action="ad.user_deactivated", subject_type="user", subject_id=uid, detail={"reason": "gone" if gone else "disabled", "sam_account": row[0], "by": NAME}, ) return len(ended) def run(ctx: JobContext) -> Counts: conn = ctx.conn _set_minutes(refresh_minutes(conn, ctx.settings)) jobs.reload_departments(conn) uids = live_users(conn) counts: Counts = { "users": len(uids), "refreshed": 0, "roles_changed": 0, "deactivated": 0, "roles_lost": 0, "sessions_ended": 0, } if not uids: return counts # Every AD read first: a failure part way changes nothing d = jobs.directory(ctx) try: found = d.users_by_guid(uids) groups_of: dict[uuid.UUID, list[str]] = {} for uid, u in found.items(): if u is not None and not u.disabled: groups_of[uid] = sorted(d.groups_of(u.dn)) except LdapUnavailable as e: ctx.degrade(f"AD unavailable, nothing changed: {e}") counts["ad"] = "unavailable" return counts with conn.transaction(): for uid in uids: u = found.get(uid) if u is None or u.disabled: counts["sessions_ended"] += _deactivate(conn, uid, u) counts["deactivated"] += 1 continue grp = groups_of[uid] roles = groupmap.roles_from_groups(grp) before = userdir.roles_of(conn, uid) userdir.upsert_user(conn, u, groups=groupmap.mapped_groups(grp)) userdir.set_roles(conn, uid, roles) if sorted(before) != sorted(roles): counts["roles_changed"] += 1 if not roles: counts["roles_lost"] += 1 counts["sessions_ended"] += len(userdir.end_sessions(conn, uid, "roles_lost", by=NAME)) continue conn.execute( "UPDATE sessions SET roles_snapshot = %s, roles_checked_at = now()" " WHERE user_id = %s AND revoked_at IS NULL AND expires_at > now()", (Jsonb({"roles": roles, "groups": grp}), uid), ) counts["refreshed"] += 1 return counts def cadence(_: FeedSettings, now_site: datetime) -> int: """Every roles_refresh_minutes. The value is read at each run, so a changed setting applies after the next.""" with _lock: return _minutes * 60 SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)