Phase 9: Admin completion, reconcile, notifications, integrity, backups
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

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:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+15 -3
View File
@@ -15,7 +15,7 @@ from fastapi import Depends, Request, Response
from psycopg.types.json import Jsonb
from .. import audit, db, events
from ..auth import devices, sessions
from ..auth import devices, secsettings, sessions
from ..auth import directory as userdir
from ..auth import groups as groupmap
from ..auth.ldap import Directory, DirectoryProtocol, LdapUnavailable
@@ -48,6 +48,15 @@ def settings_of(request: Request) -> Settings:
return s
def effective_settings(request: Request, conn: Any) -> Settings:
"""The settings with the Admin's security values applied, read once per request."""
s: Settings | None = getattr(request.state, "sec_settings", None)
if s is None:
s = secsettings.overlay(conn, settings_of(request))
request.state.sec_settings = s
return s
def directory_of(request: Request) -> DirectoryProtocol:
d: DirectoryProtocol | None = request.app.state.directory
if d is None:
@@ -257,9 +266,9 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
token = token or request.cookies.get(sessions.SESSION_COOKIE)
if not token:
raise ApiError(401, "no_session", "Not signed in.")
s = settings_of(request)
key = session_key(request)
conn = d.conn
s = effective_settings(request, conn)
ip = ip_of(request)
row = sessions.load(conn, key, token)
if row is None or row["revoked_at"] is not None or row["expires_at"] <= row["db_now"]:
@@ -312,7 +321,7 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
if now - row["roles_checked_at"] > timedelta(minutes=s.roles_refresh_minutes):
roles, groups = _refresh_roles(conn, directory_of(request), row, ip)
device = devices.get(conn, row["device_id"]) if row.get("device_id") else None
return Principal(
p = Principal(
session=row,
user=user,
roles=roles,
@@ -324,6 +333,9 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
ip=ip,
lock_minutes=idle_minutes(s, roles)[1],
)
# The error handler audits a 403 against the principal it was refused to (policy.denied)
request.state.principal = p
return p
def current_principal(request: Request, d: Db = Depends(get_db)) -> Principal: # noqa: B008