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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user