Files
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
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]>
2026-10-21 18:27:09 -06:00

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)