2026-10-16 17:13:31 -06:00
|
|
|
"""Admin: users and their AD view (read-only), PIN clearing, devices, the group map, AD refresh, attempts, storage
|
|
|
|
|
and alerts.
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
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
|
2026-10-21 18:27:09 -06:00
|
|
|
from ..domain import assets, profiles, scan
|
2026-10-16 07:40:29 -06:00
|
|
|
from .account import active_sessions
|
2026-10-16 17:13:31 -06:00
|
|
|
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
|
2026-10-16 07:40:29 -06:00
|
|
|
from .errors import ApiError
|
2026-10-21 18:27:09 -06:00
|
|
|
from .jobs import request_run, run_out
|
|
|
|
|
from .packets import WRITE
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
def _user_row(r: dict[str, Any], roles: list[str], limits: dict[str, list[int]] | None = None) -> dict[str, Any]:
|
2026-10-16 07:40:29 -06:00
|
|
|
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"],
|
2026-10-21 18:27:09 -06:00
|
|
|
"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"},
|
2026-10-16 07:40:29 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()
|
2026-10-21 18:27:09 -06:00
|
|
|
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]
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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.")
|
2026-10-21 18:27:09 -06:00
|
|
|
out = _user_row(
|
|
|
|
|
r, _roles_by_user(conn, [user_id]).get(user_id, []), profiles.station_limits(conn, [user_id]).get(user_id)
|
|
|
|
|
)
|
2026-10-16 07:40:29 -06:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
def _groups_cache(request: Request) -> dict[str, Any]:
|
2026-10-16 07:40:29 -06:00
|
|
|
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
|
2026-10-21 18:27:09 -06:00
|
|
|
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)
|
2026-10-16 07:40:29 -06:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 18:27:09 -06:00
|
|
|
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)
|
2026-10-16 07:40:29 -06:00
|
|
|
def ad_refresh(
|
|
|
|
|
request: Request,
|
|
|
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
|
|
|
|
d: Db = Depends(get_db), # noqa: B008
|
|
|
|
|
) -> dict[str, Any]:
|
2026-10-21 18:27:09 -06:00
|
|
|
"""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")
|
2026-10-16 07:40:29 -06:00
|
|
|
request.app.state.groups_cache = None
|
2026-10-21 18:27:09 -06:00
|
|
|
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}
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
]
|
2026-10-16 17:13:31 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 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))
|