"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts. 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 .account import active_sessions from .deps import Db, Principal, admin_principal, directory_of, get_db from .errors import ApiError 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]) -> 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"], } @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() roles = _roles_by_user(conn, [r["id"] for r in rows]) return [_user_row(r, roles.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, [])) 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 _group_map_view(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 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) @router.post("/ad/refresh") 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 request.app.state.groups_cache = None return res.as_dict() @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 ]