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]>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user