310 lines
11 KiB
Python
310 lines
11 KiB
Python
"""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
|
||
|
|
]
|