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
+61 -12
View File
@@ -22,10 +22,12 @@ from ..auth import groups as groupmap
from ..auth import pin as pins
from ..auth.ldap import LdapUnavailable
from ..auth.signin import ldap_down
from ..domain import assets, scan
from ..domain import assets, profiles, scan
from .account import active_sessions
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
from .errors import ApiError
from .jobs import request_run, run_out
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -61,7 +63,7 @@ USER_SELECT = (
)
def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
def _user_row(r: dict[str, Any], roles: list[str], limits: dict[str, list[int]] | None = None) -> dict[str, Any]:
flags = r.get("directory_flags") or {}
return {
"id": str(r["id"]),
@@ -79,6 +81,10 @@ def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
},
"last_signin_at": r["last_signin_at"],
"ad_synced_at": r["ad_synced_at"],
"welder_stamp": r.get("welder_stamp"),
"profile_active": bool(r.get("profile_active", True)),
"station_limits": limits or {},
"directory_flags": {k: v for k, v in flags.items() if k != "groups"},
}
@@ -100,8 +106,10 @@ def users(
).fetchall()
else:
rows = cur.execute(USER_SELECT + " ORDER BY lower(u.display_name) LIMIT 500").fetchall()
roles = _roles_by_user(conn, [r["id"] for r in rows])
return [_user_row(r, roles.get(r["id"], [])) for r in rows]
ids = [r["id"] for r in rows]
roles = _roles_by_user(conn, ids)
limits = profiles.station_limits(conn, ids)
return [_user_row(r, roles.get(r["id"], []), limits.get(r["id"])) for r in rows]
@router.get("/users/{user_id}")
@@ -116,7 +124,9 @@ def user_detail(
r = cur.execute(USER_SELECT + " WHERE u.id = %s", (user_id,)).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such user.")
out = _user_row(r, _roles_by_user(conn, [user_id]).get(user_id, []))
out = _user_row(
r, _roles_by_user(conn, [user_id]).get(user_id, []), profiles.station_limits(conn, [user_id]).get(user_id)
)
directory = directory_of(request)
try:
u = directory.get_user_by_guid(user_id)
@@ -238,7 +248,7 @@ def revoke_device(
return Response(status_code=204)
def _group_map_view(request: Request) -> dict[str, Any]:
def _groups_cache(request: Request) -> dict[str, Any]:
cache: dict[str, Any] | None = getattr(request.app.state, "groups_cache", None)
if cache is None or time.monotonic() - cache["t"] > GROUPS_CACHE_SECONDS:
try:
@@ -247,6 +257,17 @@ def _group_map_view(request: Request) -> dict[str, Any]:
in_ad = None
cache = {"t": time.monotonic(), "checked_at": datetime.now(UTC), "in_ad": in_ad}
request.app.state.groups_cache = cache
return cache
def groups_in_ou(request: Request) -> list[str] | None:
"""The group names in the PacketStation OU (cached GROUPS_CACHE_SECONDS), or None when AD is not reachable."""
names: list[str] | None = _groups_cache(request)["in_ad"]
return names
def _group_map_view(request: Request) -> dict[str, Any]:
cache = _groups_cache(request)
names: list[str] | None = cache["in_ad"]
lower = {n.lower() for n in names} if names is not None else None
gm = groupmap.current()
@@ -269,18 +290,46 @@ def group_map(request: Request, p: Principal = Depends(admin_principal)) -> dict
return _group_map_view(request)
@router.post("/ad/refresh")
AD_RECONCILE_JOB = "ad-reconcile"
REPORT_KEYS: dict[str, Any] = {
"groups_missing": list,
"groups_extra": list,
"users_seen": int,
"created": int,
"updated": int,
"deactivated": int,
"sessions_ended": int,
"emp_missing": list,
"emp_duplicate": list,
}
@router.post("/ad/refresh", status_code=202, dependencies=WRITE)
def ad_refresh(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
try:
res = userdir.refresh_from_ad(d.conn, directory_of(request), actor=p.actor, ip=p.ip)
except LdapUnavailable as e:
raise ldap_down(e) from None
"""Ask the worker for an ad-reconcile run now. The result shows on GET /ad/reconcile."""
res = request_run(d.conn, p, AD_RECONCILE_JOB, action="ad.refresh_requested")
request.app.state.groups_cache = None
return res.as_dict()
return {**res, "job": AD_RECONCILE_JOB}
@router.get("/ad/reconcile")
def ad_reconcile(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
"""The newest finished ad-reconcile run and its report."""
with d.conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM job_runs WHERE job = %s AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
(AD_RECONCILE_JOB,),
).fetchone()
pending = cur.execute(
"SELECT count(*) AS n FROM job_requests WHERE job = %s AND claimed_at IS NULL", (AD_RECONCILE_JOB,)
).fetchone()
counts = (r or {}).get("counts") or {}
report = {k: counts.get(k, kind()) for k, kind in REPORT_KEYS.items()}
return {"run": run_out(r), "report": report, "pending": int(pending["n"]) if pending else 0}
@router.get("/signin-attempts")