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]>
138 lines
4.8 KiB
Python
138 lines
4.8 KiB
Python
"""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)
|