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