The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board (waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag. Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays blocked until every line is pulled or short and every pulled line has a heat. Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind, optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events table (migration 0005) shown on the Admin Storage tab. Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed. Co-Authored-By: Claude Fable 5.1 <[email protected]>
411 lines
14 KiB
Python
411 lines
14 KiB
Python
"""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, scan
|
|
from .account import active_sessions
|
|
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
|
|
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
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------- 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))
|