"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts, storage and alerts. Every route needs the admin role and a password session. Roles cannot be changed here: they come from AD. """ from __future__ import annotations import time import uuid from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, Query, Request, Response from psycopg.rows import dict_row from pydantic import BaseModel, Field from .. import audit from ..auth import devices, sessions from ..auth import directory as userdir 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, 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"]) GROUPS_CACHE_SECONDS = 300 ROLES_NOTE = "Roles come from AD groups. Ask IT to change them." class ReasonBody(BaseModel): reason: str = Field(max_length=500) def _like(q: str) -> str: return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" def _roles_by_user(conn: Any, ids: list[Any]) -> dict[Any, list[str]]: out: dict[Any, list[str]] = {} if not ids: return out rows = conn.execute( "SELECT user_id, role::text, dept_code FROM user_roles WHERE user_id = ANY(%s)", (ids,) ).fetchall() for uid, kind, dept in rows: out.setdefault(uid, []).append(groupmap.role_from_row(kind, dept)) gm = groupmap.current() return {k: gm.sort_roles(v) for k, v in out.items()} USER_SELECT = ( "SELECT u.*, p.user_id IS NOT NULL AS pin_set, p.disabled_at IS NOT NULL AS pin_disabled," " (SELECT count(*) FROM users x WHERE x.emp_no = u.emp_no) AS emp_count" " FROM users u LEFT JOIN user_pins p ON p.user_id = u.id" ) 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"]), "sam_account": r["sam_account"], "display_name": r["display_name"], "emp_no": r["emp_no"], "email": r["email"], "active": r["active"], "roles": roles, "pin": {"set": bool(r["pin_set"]), "disabled": bool(r["pin_disabled"])}, "flags": { "emp_missing": not r["emp_no"], "emp_duplicate": bool(r["emp_no"]) and int(r["emp_count"] or 0) > 1, "disabled": bool(flags.get("disabled")) or bool(flags.get("gone")), }, "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"}, } @router.get("/users") def users( p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 q: str = Query("", max_length=100), ) -> list[dict[str, Any]]: conn = d.conn q = q.strip() with conn.cursor(row_factory=dict_row) as cur: if q: pat = _like(q) rows = cur.execute( USER_SELECT + " WHERE u.sam_account ILIKE %s OR u.display_name ILIKE %s OR u.email ILIKE %s" " OR u.emp_no = %s ORDER BY lower(u.display_name) LIMIT 500", (pat, pat, pat, q), ).fetchall() else: rows = cur.execute(USER_SELECT + " ORDER BY lower(u.display_name) LIMIT 500").fetchall() 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}") def user_detail( user_id: uuid.UUID, request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn with conn.cursor(row_factory=dict_row) as cur: 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, []), profiles.station_limits(conn, [user_id]).get(user_id) ) directory = directory_of(request) try: u = directory.get_user_by_guid(user_id) out["groups"] = sorted(directory.groups_of(u.dn)) if u is not None else [] out["groups_source"] = "ad" out["in_ad"] = u is not None except LdapUnavailable: out["groups"] = list((r.get("directory_flags") or {}).get("groups", [])) out["groups_source"] = "cache" out["in_ad"] = None out["sessions"] = active_sessions(conn, user_id) out["note"] = ROLES_NOTE return out @router.get("/ad/lookup") def ad_lookup( request: Request, p: Principal = Depends(admin_principal), # noqa: B008 login: str = Query(..., min_length=1, max_length=256), ) -> dict[str, Any]: directory = directory_of(request) try: u = directory.find_user(login) if u is None: return {"found": False, "user": None, "groups": [], "roles": [], "disabled": False} found = sorted(directory.groups_of(u.dn)) except LdapUnavailable as e: raise ldap_down(e) from None return { "found": True, "user": userdir.ad_user_public(u), "groups": found, "roles": groupmap.roles_from_groups(found), "disabled": u.disabled, "note": ROLES_NOTE, } @router.delete("/users/{user_id}/pin", status_code=204) def clear_pin( user_id: uuid.UUID, body: ReasonBody, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> Response: reason = body.reason.strip() if not reason: raise ApiError(400, "reason_required", "Give a reason for clearing the PIN.") conn = d.conn with conn.transaction(): if not pins.clear(conn, user_id): raise ApiError(404, "not_found", "That user has no PIN.") audit.append( conn, action="pin.cleared", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="user", subject_id=user_id, it_reason=reason, ip=p.ip, ) return Response(status_code=204) @router.get("/devices") def device_list(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008 with d.conn.cursor(row_factory=dict_row) as cur: rows = cur.execute( "SELECT d.id, d.name, d.registered_by, u.display_name AS registered_by_name, d.registered_at," " d.last_seen_at, d.revoked_at," " (SELECT count(*) FROM sessions s WHERE s.device_id = d.id AND s.revoked_at IS NULL" " AND s.expires_at > now()) AS active_sessions" " FROM devices d LEFT JOIN users u ON u.id = d.registered_by" " ORDER BY d.revoked_at IS NOT NULL, lower(d.name)" ).fetchall() return [ { "id": str(r["id"]), "name": r["name"], "registered_by": ( {"id": str(r["registered_by"]), "display_name": r["registered_by_name"]} if r["registered_by"] else None ), "registered_at": r["registered_at"], "last_seen_at": r["last_seen_at"], "revoked_at": r["revoked_at"], "active_sessions": int(r["active_sessions"]), } for r in rows ] @router.delete("/devices/{device_id}", status_code=204) def revoke_device( device_id: uuid.UUID, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> Response: conn = d.conn found = devices.get(conn, device_id) if found is None: raise ApiError(404, "not_found", "No such device.") with conn.transaction(): if devices.revoke(conn, device_id, p.user_id): ended = sessions.revoke_where(conn, "device_id = %s AND kind = 'pin'", (device_id,), "device_revoked") audit.append( conn, action="device.revoked", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="device", subject_id=device_id, detail={"name": found["name"], "sessions_ended": len(ended)}, ip=p.ip, ) return Response(status_code=204) 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: in_ad: list[str] | None = directory_of(request).list_groups() except LdapUnavailable: 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() out = [] for name, role in gm.entries: out.append( { "name": name, "role": str(role), "dept_code": role.dept_code, "in_ad": None if lower is None else name.lower() in lower, } ) extra = sorted(n for n in names or [] if gm.role_for(n) is None) return {"checked_at": cache["checked_at"], "ad_reachable": names is not None, "groups": out, "extra_in_ou": extra} @router.get("/groups") def group_map(request: Request, p: Principal = Depends(admin_principal)) -> dict[str, Any]: # noqa: B008 return _group_map_view(request) 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]: """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, "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") def signin_attempts( p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 limit: int = Query(100, ge=1, le=1000), ) -> list[dict[str, Any]]: with d.conn.cursor(row_factory=dict_row) as cur: rows = cur.execute( "SELECT a.id, a.at, a.account, host(a.ip) AS ip, a.kind, a.reason, a.user_id, a.session_id," " u.display_name FROM signin_attempts a LEFT JOIN users u ON u.id = a.user_id" " WHERE NOT a.ok ORDER BY a.id DESC LIMIT %s", (limit,), ).fetchall() return [ { "id": r["id"], "at": r["at"], "account": r["account"], "ip": r["ip"], "kind": r["kind"], "reason": r["reason"], "user_id": str(r["user_id"]) if r["user_id"] else None, "display_name": r["display_name"], } for r in rows ] # ---------------------------------------------------------------- storage and alerts PING_MAX_SECONDS = 5.0 ALERT_SELECT = ( "SELECT n.*, u.display_name AS acknowledged_name FROM notification_events n" " LEFT JOIN users u ON u.id = n.acknowledged_by" ) def _alert_row(r: dict[str, Any]) -> dict[str, Any]: by = r.get("acknowledged_by") return { "id": r["id"], "kind": r["kind"], "severity": r["severity"], "subject": r["subject"], "detail": r["detail"] or {}, "created_at": r["created_at"], "resolved_at": r["resolved_at"], "acknowledged_at": r["acknowledged_at"], "acknowledged_by": {"id": str(by), "display_name": r.get("acknowledged_name")} if by else None, } @router.get("/storage") def storage( request: Request, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: s = settings_of(request) usage = assets.usage_for(d.conn, s) with d.conn.cursor(row_factory=dict_row) as cur: r = cur.execute( ALERT_SELECT + " WHERE n.kind = %s AND coalesce(n.subject, '') = %s AND n.resolved_at IS NULL" " ORDER BY n.id LIMIT 1", (assets.QUOTA_KIND, assets.QUOTA_SUBJECT), ).fetchone() scanner = scan.scanner_for(s) clamav: dict[str, Any] = {"configured": scanner is not None, "host": None, "port": None, "reachable": None} if scanner is not None: clamav.update( host=scanner.host, port=scanner.port, reachable=scanner.ping(timeout=min(scanner.timeout, PING_MAX_SECONDS)), ) return { "usage": usage, "quota_warn_pct": int(s.assets_quota_warn_pct), "quota_bytes": int(s.assets_quota_bytes), "alert": _alert_row(dict(r)) if r else None, "clamav": clamav, } @router.get("/alerts") def alerts( p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: with d.conn.cursor(row_factory=dict_row) as cur: rows = cur.execute( ALERT_SELECT + " WHERE n.resolved_at IS NULL ORDER BY n.created_at DESC, n.id DESC" ).fetchall() return {"alerts": [_alert_row(dict(r)) for r in rows]} @router.post("/alerts/{alert_id}/ack") def ack_alert( alert_id: int, p: Principal = Depends(admin_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: conn = d.conn with conn.transaction(): done = conn.execute( "UPDATE notification_events SET acknowledged_at = now(), acknowledged_by = %s" " WHERE id = %s AND acknowledged_at IS NULL RETURNING kind, subject", (p.user_id, alert_id), ).fetchone() if done is not None: audit.append( conn, action="alert.acknowledged", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="notification", subject_id=str(alert_id), detail={"kind": done[0], "subject": done[1]}, ip=p.ip, ) with conn.cursor(row_factory=dict_row) as cur: r = cur.execute(ALERT_SELECT + " WHERE n.id = %s", (alert_id,)).fetchone() if r is None: raise ApiError(404, "not_found", "No such alert.") return _alert_row(dict(r))