Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -22,10 +22,12 @@ 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 ..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"])
|
||||
|
||||
@@ -61,7 +63,7 @@ USER_SELECT = (
|
||||
)
|
||||
|
||||
|
||||
def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
|
||||
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"]),
|
||||
@@ -79,6 +81,10 @@ def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
|
||||
},
|
||||
"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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +106,10 @@ def users(
|
||||
).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]
|
||||
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}")
|
||||
@@ -116,7 +124,9 @@ def user_detail(
|
||||
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, []))
|
||||
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)
|
||||
@@ -238,7 +248,7 @@ def revoke_device(
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _group_map_view(request: Request) -> dict[str, Any]:
|
||||
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:
|
||||
@@ -247,6 +257,17 @@ def _group_map_view(request: Request) -> dict[str, Any]:
|
||||
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()
|
||||
@@ -269,18 +290,46 @@ def group_map(request: Request, p: Principal = Depends(admin_principal)) -> dict
|
||||
return _group_map_view(request)
|
||||
|
||||
|
||||
@router.post("/ad/refresh")
|
||||
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]:
|
||||
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
|
||||
"""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.as_dict()
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
|
||||
|
||||
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .. import audit
|
||||
from ..auth import groups as groupmap
|
||||
from ..domain import checklists, profiles
|
||||
from ..domain import departments as depts
|
||||
from .admin import groups_in_ou
|
||||
from .deps import Db, Principal, admin_principal, get_db
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
IMPORT_MAX_BODY = profiles.CSV_MAX_BYTES * 2 + 10_000 # JSON escaping can double the text
|
||||
|
||||
|
||||
def import_limit(request: Request) -> None:
|
||||
length = request.headers.get("content-length")
|
||||
if length is not None and length.strip().isdigit() and int(length) > IMPORT_MAX_BODY:
|
||||
raise ApiError(413, "too_large", "The file is larger than 1 MB.")
|
||||
|
||||
|
||||
def reload_groups(request: Request, conn: Any) -> None:
|
||||
"""Rebuild the group map from the departments table and drop the cached OU listing."""
|
||||
found = groupmap.load_departments(conn)
|
||||
if found:
|
||||
groupmap.use_departments(found)
|
||||
request.app.state.groups_cache = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- departments
|
||||
|
||||
|
||||
class StationIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(max_length=200)
|
||||
sort: int = Field(0, ge=0, le=100_000)
|
||||
active: bool = True
|
||||
|
||||
|
||||
class DepartmentPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(None, max_length=200)
|
||||
active: bool | None = None
|
||||
stations: list[StationIn] | None = Field(None, max_length=depts.STATIONS_MAX)
|
||||
|
||||
|
||||
class DepartmentNew(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
code: str = Field(max_length=10)
|
||||
slug: str = Field(max_length=40)
|
||||
name: str = Field(max_length=200)
|
||||
kind: str = Field(pattern="^(shop|support)$")
|
||||
weld: bool = False
|
||||
supervisor_group: bool = True
|
||||
stations: list[str] = Field(default_factory=list, max_length=depts.STATIONS_MAX)
|
||||
|
||||
|
||||
@router.get("/departments")
|
||||
def department_list(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> list[dict[str, Any]]:
|
||||
return depts.list_all(d.conn, groups_in_ou(request))
|
||||
|
||||
|
||||
@router.patch("/departments/{code}", dependencies=WRITE)
|
||||
def department_update(
|
||||
code: str,
|
||||
body: DepartmentPatch,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
depts.update(
|
||||
conn,
|
||||
p,
|
||||
code[:10],
|
||||
name=body.name,
|
||||
active=body.active,
|
||||
stations=[s.model_dump() for s in body.stations] if body.stations is not None else None,
|
||||
)
|
||||
reload_groups(request, conn)
|
||||
out = depts.get(conn, code[:10], groups_in_ou(request))
|
||||
assert out is not None
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/departments", status_code=201, dependencies=WRITE)
|
||||
def department_add(
|
||||
body: DepartmentNew,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
_, groups = depts.add(
|
||||
conn,
|
||||
p,
|
||||
code=body.code,
|
||||
slug=body.slug,
|
||||
name=body.name,
|
||||
kind=body.kind,
|
||||
weld=body.weld,
|
||||
supervisor_group=body.supervisor_group,
|
||||
stations=body.stations,
|
||||
)
|
||||
reload_groups(request, conn)
|
||||
dept = depts.get(conn, body.code.strip(), groups_in_ou(request))
|
||||
return {"department": dept, "groups_to_create": groups}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- profiles
|
||||
|
||||
|
||||
class ProfilePatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
welder_stamp: str | None = Field(None, max_length=40)
|
||||
profile_active: bool | None = None
|
||||
station_limits: dict[str, list[int]] | None = None
|
||||
|
||||
|
||||
class ImportBody(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
csv: str
|
||||
apply: bool = False
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/profile", dependencies=WRITE)
|
||||
def profile_update(
|
||||
user_id: uuid.UUID,
|
||||
body: ProfilePatch,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
out = profiles.update(d.conn, p, user_id, fields)
|
||||
return {"user_id": str(user_id), **out}
|
||||
|
||||
|
||||
@router.post("/users/import", dependencies=[Depends(import_limit)])
|
||||
def profile_import(
|
||||
body: ImportBody,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
return profiles.import_profiles(d.conn, p, body.csv, apply=body.apply)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- checklists
|
||||
|
||||
RX_BRACES = re.compile(r"\{[^{}]*\}")
|
||||
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
|
||||
|
||||
|
||||
class ChecklistsPut(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
value: dict[str, Any]
|
||||
version: int = Field(ge=0)
|
||||
|
||||
|
||||
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
|
||||
"""The checklist templates cleaned, and one error line per problem."""
|
||||
errors: list[str] = []
|
||||
out: dict[str, list[dict[str, str]]] = {}
|
||||
if not isinstance(value, dict):
|
||||
return {}, ["value must be an object with weld and plain lists"]
|
||||
extra = sorted(k for k in value if k not in ("weld", "plain"))
|
||||
if extra:
|
||||
errors.append(f"unknown list: {', '.join(extra)}")
|
||||
for name in ("weld", "plain"):
|
||||
items = value.get(name)
|
||||
if not isinstance(items, list) or not items:
|
||||
errors.append(f"{name}: at least one item")
|
||||
continue
|
||||
if len(items) > checklists.ITEMS_MAX:
|
||||
errors.append(f"{name}: at most {checklists.ITEMS_MAX} items")
|
||||
seen: set[str] = set()
|
||||
clean: list[dict[str, str]] = []
|
||||
for i, it in enumerate(items, start=1):
|
||||
where = f"{name} item {i}"
|
||||
if not isinstance(it, dict):
|
||||
errors.append(f"{where}: must be an object with group, key and text")
|
||||
continue
|
||||
group = " ".join(str(it.get("group") or "").split())
|
||||
key = str(it.get("key") or "").strip()
|
||||
text = " ".join(str(it.get("text") or "").split())
|
||||
if not checklists.RX_KEY.match(key):
|
||||
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
|
||||
elif key in seen:
|
||||
errors.append(f"{where}: key {key} is used twice")
|
||||
seen.add(key)
|
||||
if not group:
|
||||
errors.append(f"{where}: the group is empty")
|
||||
if not text:
|
||||
errors.append(f"{where}: the text is empty")
|
||||
if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX:
|
||||
errors.append(f"{where}: at most {checklists.TEXT_MAX} characters")
|
||||
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
|
||||
stray = RX_BRACES.sub("", text)
|
||||
if bad:
|
||||
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
|
||||
elif "{" in stray or "}" in stray:
|
||||
errors.append(f"{where}: a brace without its pair")
|
||||
clean.append({"group": group, "key": key, "text": text})
|
||||
out[name] = clean
|
||||
return out, errors
|
||||
|
||||
|
||||
def _checklists_out(conn: Any) -> dict[str, Any]:
|
||||
row = conn.execute("SELECT version FROM settings WHERE key = %s", (checklists.SETTINGS_KEY,)).fetchone()
|
||||
return {
|
||||
"value": checklists.load_templates(conn),
|
||||
"defaults": checklists.DEFAULT_TEMPLATES,
|
||||
"version": int(row[0]) if row else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/checklists")
|
||||
def checklists_get(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
|
||||
return _checklists_out(d.conn)
|
||||
|
||||
|
||||
@router.put("/checklists", dependencies=WRITE)
|
||||
def checklists_put(
|
||||
body: ChecklistsPut,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
value, errors = check_templates(body.value)
|
||||
if errors:
|
||||
raise ApiError(422, "bad_checklists", errors[0], extra={"errors": errors})
|
||||
with conn.transaction():
|
||||
row = conn.execute(
|
||||
"SELECT version FROM settings WHERE key = %s FOR UPDATE", (checklists.SETTINGS_KEY,)
|
||||
).fetchone()
|
||||
current = int(row[0]) if row else 0
|
||||
if body.version != current:
|
||||
raise ApiError(
|
||||
409,
|
||||
"version_conflict",
|
||||
"Someone else changed the checklists. Reload and try again.",
|
||||
extra={"current": jsonable_encoder(_checklists_out(conn))},
|
||||
)
|
||||
before = checklists.load_templates(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
|
||||
" updated_at = now(), updated_by = EXCLUDED.updated_by",
|
||||
(checklists.SETTINGS_KEY, Jsonb(value), p.user_id),
|
||||
)
|
||||
audit.append(
|
||||
conn,
|
||||
action="admin.checklists_updated",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="settings",
|
||||
subject_id=checklists.SETTINGS_KEY,
|
||||
detail={"before": before, "after": value},
|
||||
ip=p.ip,
|
||||
)
|
||||
return _checklists_out(conn)
|
||||
@@ -0,0 +1,634 @@
|
||||
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
|
||||
stamp check, and the directory flags.
|
||||
|
||||
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from psycopg.rows import dict_row
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from .. import audit
|
||||
from ..auth import secsettings
|
||||
from ..domain import security_alerts
|
||||
from ..pdf import stamps, tracecsv
|
||||
from .account import LOG_PREFIXES
|
||||
from .deps import Db, Principal, admin_principal, get_db, idle_minutes, settings_of
|
||||
from .errors import ApiError
|
||||
from .jobs import run_out
|
||||
from .packets import WRITE
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
CSV = "text/csv; charset=utf-8"
|
||||
PDF_MAX_BYTES = 25 * 1024 * 1024
|
||||
FORM_SLACK = 64 * 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- security settings
|
||||
|
||||
|
||||
class SecPut(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
values: dict[str, Any]
|
||||
version: int = Field(ge=0)
|
||||
|
||||
|
||||
def _sec_out(conn: Any, request: Request) -> dict[str, Any]:
|
||||
s = settings_of(request)
|
||||
stored, version, updated_at, updated_by = secsettings.load_versioned(conn)
|
||||
defaults = secsettings.defaults(s)
|
||||
return {
|
||||
"values": {**defaults, **stored},
|
||||
"defaults": defaults,
|
||||
"bounds": {k: list(v) for k, v in secsettings.BOUNDS.items()},
|
||||
"version": version,
|
||||
"updated_at": updated_at,
|
||||
"updated_by": str(updated_by) if updated_by else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/security/settings")
|
||||
def security_settings(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
return _sec_out(d.conn, request)
|
||||
|
||||
|
||||
@router.put("/security/settings", dependencies=WRITE)
|
||||
def security_settings_put(
|
||||
body: SecPut,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
try:
|
||||
secsettings.save(conn, settings_of(request), who=p, values=body.values, version=body.version)
|
||||
except secsettings.SecSettingsError as e:
|
||||
raise ApiError(422, "bad_settings", e.errors[0], extra={"errors": e.errors}) from None
|
||||
except secsettings.VersionConflict:
|
||||
raise ApiError(
|
||||
409,
|
||||
"version_conflict",
|
||||
"Someone else changed the security settings. Reload and try again.",
|
||||
extra={"current": jsonable_encoder(_sec_out(conn, request))},
|
||||
) from None
|
||||
return _sec_out(conn, request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- alerts and integrity
|
||||
|
||||
|
||||
@router.get("/security/alerts")
|
||||
def security_alert_list(
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
) -> list[dict[str, Any]]:
|
||||
return security_alerts.alerts(d.conn, hours)
|
||||
|
||||
|
||||
@router.get("/security/integrity")
|
||||
def security_integrity(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
|
||||
with d.conn.cursor(row_factory=dict_row) as cur:
|
||||
r = cur.execute(
|
||||
"SELECT * FROM job_runs WHERE job = 'integrity' AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
findings = (
|
||||
cur.execute(
|
||||
"SELECT kind, subject, detail, at FROM integrity_findings WHERE run_id = %s ORDER BY id", (r["id"],)
|
||||
).fetchall()
|
||||
if r
|
||||
else []
|
||||
)
|
||||
run = None
|
||||
if r is not None:
|
||||
full = run_out(r) or {}
|
||||
run = {k: full.get(k) for k in ("id", "started_at", "finished_at", "status", "counts")}
|
||||
return {
|
||||
"run": run,
|
||||
"findings": [
|
||||
{"kind": f["kind"], "subject": f["subject"], "detail": f["detail"] or {}, "at": f["at"]} for f in findings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- access log
|
||||
|
||||
ACCESS_LIMIT_MAX = 1000
|
||||
CSV_ROWS_MAX = 50_000
|
||||
FAIL_ACTIONS = ("auth.signin_failed", "auth.lockout", "auth.signin_burst")
|
||||
RX_CURSOR = re.compile(r"^(?P<at>[0-9T:.+\-Z ]{10,40}),(?P<src>[01]),(?P<id>[0-9]{1,19})$")
|
||||
|
||||
ACCESS_SQL = """
|
||||
SELECT x.at, x.src, x.id, x.source, x.kind, x.ok, coalesce(x.account, u.sam_account) AS account, x.user_id,
|
||||
u.display_name, host(x.ip) AS ip, x.detail
|
||||
FROM (
|
||||
SELECT a.at, 0 AS src, a.id, 'attempt' AS source, a.kind, a.ok, a.account, a.user_id, a.ip,
|
||||
jsonb_strip_nulls(jsonb_build_object('reason', a.reason, 'session_id', a.session_id)) AS detail
|
||||
FROM signin_attempts a
|
||||
UNION ALL
|
||||
SELECT e.at, 1, e.id, 'audit', e.action, NOT (e.action = ANY(%(fail)s)), e.detail->>'account', e.user_id, e.ip,
|
||||
coalesce(e.detail, '{{}}'::jsonb)
|
||||
FROM audit_events e WHERE e.action LIKE ANY(%(prefixes)s)
|
||||
) x LEFT JOIN users u ON u.id = x.user_id
|
||||
WHERE true{where}
|
||||
ORDER BY x.at DESC, x.src DESC, x.id DESC
|
||||
LIMIT %(limit)s
|
||||
"""
|
||||
|
||||
|
||||
def _access_where(
|
||||
*,
|
||||
since: datetime | None,
|
||||
until: datetime | None,
|
||||
user_id: uuid.UUID | None,
|
||||
q: str,
|
||||
kind: str,
|
||||
ok: bool | None,
|
||||
ip: str,
|
||||
before: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
terms: list[str] = []
|
||||
params: dict[str, Any] = {"fail": list(FAIL_ACTIONS), "prefixes": list(LOG_PREFIXES)}
|
||||
if since is not None:
|
||||
terms.append("x.at >= %(since)s")
|
||||
params["since"] = since
|
||||
if until is not None:
|
||||
terms.append("x.at < %(until)s")
|
||||
params["until"] = until
|
||||
if user_id is not None:
|
||||
terms.append("x.user_id = %(user_id)s")
|
||||
params["user_id"] = user_id
|
||||
q = q.strip()
|
||||
if q:
|
||||
pat = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
terms.append(
|
||||
"(x.account ILIKE %(q)s OR u.sam_account ILIKE %(q)s OR u.display_name ILIKE %(q)s"
|
||||
" OR u.emp_no = %(q_exact)s)"
|
||||
)
|
||||
params["q"], params["q_exact"] = pat, q
|
||||
kind = kind.strip()
|
||||
if kind:
|
||||
terms.append("x.kind = %(kind)s")
|
||||
params["kind"] = kind
|
||||
if ok is not None:
|
||||
terms.append("x.ok = %(ok)s")
|
||||
params["ok"] = ok
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
try:
|
||||
net = ipaddress.ip_network(ip, strict=False)
|
||||
except ValueError:
|
||||
raise ApiError(422, "invalid_request", "ip must be an address or a network like 10.1.2.0/24.") from None
|
||||
terms.append("x.ip <<= %(ip)s::inet")
|
||||
params["ip"] = str(net)
|
||||
before = before.strip()
|
||||
if before:
|
||||
m = RX_CURSOR.match(before)
|
||||
if m is None:
|
||||
raise ApiError(422, "invalid_request", "before is the next_before value of a previous page.")
|
||||
try:
|
||||
at = datetime.fromisoformat(m["at"].replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ApiError(422, "invalid_request", "before is the next_before value of a previous page.") from None
|
||||
terms.append("(x.at, x.src, x.id) < (%(b_at)s, %(b_src)s, %(b_id)s)")
|
||||
params.update(b_at=at, b_src=int(m["src"]), b_id=int(m["id"]))
|
||||
return "".join(f" AND {t}" for t in terms), params
|
||||
|
||||
|
||||
def _access_rows(conn: Any, where: str, params: dict[str, Any], limit: int) -> list[dict[str, Any]]:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
rows: list[dict[str, Any]] = cur.execute(ACCESS_SQL.format(where=where), {**params, "limit": limit}).fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def _access_out(r: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": f"{r['source']}:{r['id']}",
|
||||
"at": r["at"],
|
||||
"source": r["source"],
|
||||
"kind": r["kind"],
|
||||
"ok": bool(r["ok"]),
|
||||
"account": r["account"],
|
||||
"user_id": str(r["user_id"]) if r["user_id"] else None,
|
||||
"display_name": r["display_name"],
|
||||
"ip": r["ip"],
|
||||
"detail": r["detail"] or {},
|
||||
}
|
||||
|
||||
|
||||
class AccessFilters(BaseModel):
|
||||
since: datetime | None = None
|
||||
until: datetime | None = None
|
||||
user_id: uuid.UUID | None = None
|
||||
q: str = Field("", max_length=200)
|
||||
kind: str = Field("", max_length=60)
|
||||
ok: bool | None = None
|
||||
ip: str = Field("", max_length=60)
|
||||
|
||||
|
||||
def access_filters(
|
||||
since: datetime | None = Query(None), # noqa: B008
|
||||
until: datetime | None = Query(None), # noqa: B008
|
||||
user_id: uuid.UUID | None = Query(None), # noqa: B008
|
||||
q: str = Query("", max_length=200),
|
||||
kind: str = Query("", max_length=60),
|
||||
ok: bool | None = Query(None),
|
||||
ip: str = Query("", max_length=60),
|
||||
) -> AccessFilters:
|
||||
return AccessFilters(since=since, until=until, user_id=user_id, q=q, kind=kind, ok=ok, ip=ip)
|
||||
|
||||
|
||||
@router.get("/access-log")
|
||||
def access_log(
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
f: AccessFilters = Depends(access_filters), # noqa: B008
|
||||
limit: int = Query(100, ge=1, le=ACCESS_LIMIT_MAX),
|
||||
before: str = Query("", max_length=80),
|
||||
) -> dict[str, Any]:
|
||||
where, params = _access_where(**f.model_dump(), before=before)
|
||||
rows = _access_rows(d.conn, where, params, limit)
|
||||
nxt = None
|
||||
if len(rows) == limit:
|
||||
last = rows[-1]
|
||||
nxt = f"{last['at'].astimezone(UTC).isoformat()},{last['src']},{last['id']}"
|
||||
return {"rows": [_access_out(r) for r in rows], "next_before": nxt}
|
||||
|
||||
|
||||
CSV_HEADER = ("at", "source", "kind", "ok", "account", "display_name", "user_id", "ip", "detail")
|
||||
|
||||
|
||||
@router.get("/access-log.csv")
|
||||
def access_log_csv(
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
f: AccessFilters = Depends(access_filters), # noqa: B008
|
||||
) -> Response:
|
||||
conn = d.conn
|
||||
where, params = _access_where(**f.model_dump(), before="")
|
||||
rows = _access_rows(conn, where, params, CSV_ROWS_MAX)
|
||||
lines: list[list[Any]] = [list(CSV_HEADER)]
|
||||
for r in rows:
|
||||
o = _access_out(r)
|
||||
lines.append(
|
||||
[
|
||||
o["at"].astimezone(UTC).isoformat(),
|
||||
o["source"],
|
||||
o["kind"],
|
||||
"yes" if o["ok"] else "no",
|
||||
o["account"],
|
||||
o["display_name"],
|
||||
o["user_id"],
|
||||
o["ip"],
|
||||
json.dumps(o["detail"], sort_keys=True, default=str, separators=(",", ":")),
|
||||
]
|
||||
)
|
||||
text = tracecsv.to_csv(lines) + "\r\n"
|
||||
filters = {k: (str(v) if v is not None else None) for k, v in f.model_dump().items() if v not in (None, "")}
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO print_log (by, by_name, session_id, kind, what, pages, ip)"
|
||||
" VALUES (%s, %s, %s, 'access-log-csv', %s, NULL, %s)",
|
||||
(p.user_id, p.user.get("display_name"), p.session_id, f"Access log CSV ({len(rows)} rows)", p.ip),
|
||||
)
|
||||
audit.append(
|
||||
conn,
|
||||
action="admin.access_log_exported",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="access_log",
|
||||
subject_id="csv",
|
||||
detail={"rows": len(rows), "filters": filters, "capped": len(rows) >= CSV_ROWS_MAX},
|
||||
ip=p.ip,
|
||||
)
|
||||
day = datetime.now(UTC).strftime("%Y%m%d")
|
||||
return Response(
|
||||
content=text.encode("utf-8"),
|
||||
media_type=CSV,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="access-log-{day}.csv"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- who is in now
|
||||
|
||||
|
||||
@router.get("/presence")
|
||||
def presence(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
conn = d.conn
|
||||
s = secsettings.overlay(conn, settings_of(request))
|
||||
widest = max(s.session_idle_minutes, s.session_desk_minutes)
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = cur.execute(
|
||||
"SELECT s.id, s.user_id, u.display_name, s.kind::text AS kind, dv.name AS device_name, host(s.ip) AS ip,"
|
||||
" s.created_at, s.last_seen_at, s.locked_since, s.roles_snapshot, now() AS db_now,"
|
||||
" now() - s.last_seen_at AS idle"
|
||||
" FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN devices dv ON dv.id = s.device_id"
|
||||
" WHERE s.revoked_at IS NULL AND s.expires_at > now()"
|
||||
" AND s.last_seen_at > now() - make_interval(mins => %s::int)"
|
||||
" ORDER BY lower(u.display_name), s.created_at",
|
||||
(widest,),
|
||||
).fetchall()
|
||||
streams: dict[str, dict[str, Any]] = {}
|
||||
hub = getattr(request.app.state, "events", None)
|
||||
if hub is not None:
|
||||
for screen, users in hub.presence_by_screen().items():
|
||||
for u in users:
|
||||
streams.setdefault(str(u["user_id"]), {"screen": None if screen == "none" else screen, **u})
|
||||
at = rows[0]["db_now"] if rows else datetime.now(UTC)
|
||||
out = []
|
||||
for r in rows:
|
||||
roles = list((r["roles_snapshot"] or {}).get("roles", []))
|
||||
idle_min, _ = idle_minutes(s, roles)
|
||||
if r["idle"].total_seconds() > idle_min * 60:
|
||||
continue
|
||||
stream = streams.get(str(r["user_id"]))
|
||||
out.append(
|
||||
{
|
||||
"session_id": str(r["id"]),
|
||||
"user_id": str(r["user_id"]),
|
||||
"display_name": r["display_name"],
|
||||
"kind": r["kind"],
|
||||
"device_name": r["device_name"],
|
||||
"ip": r["ip"],
|
||||
"created_at": r["created_at"],
|
||||
"last_seen_at": r["last_seen_at"],
|
||||
"locked": r["locked_since"] is not None,
|
||||
"screen": stream["screen"] if stream else None,
|
||||
"packet_id": stream.get("packet_id") if stream else None,
|
||||
}
|
||||
)
|
||||
return {"at": at, "sessions": out}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- stamp check
|
||||
|
||||
RX_SIG_IN_TEXT = re.compile(r"stamp\s+([0-9a-fA-F]{16})\b")
|
||||
RX_SIG_ALONE = re.compile(r"^\s*([0-9a-fA-F]{16})\s*$")
|
||||
RX_UTC = re.compile(r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) UTC")
|
||||
TEXT_MAX = 4000
|
||||
PDF_STAMPS_MAX = 50
|
||||
|
||||
|
||||
class StampText(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
text: str = Field(max_length=TEXT_MAX)
|
||||
|
||||
|
||||
def _squash(text: str) -> str:
|
||||
return "".join(text.split())
|
||||
|
||||
|
||||
def _sign_candidates(conn: Any, r: dict[str, Any]) -> list[tuple[Any, str, str | None]]:
|
||||
"""(packet_id, signed kind, seal hash) the print could have been signed with, from its print_log kind."""
|
||||
kind, pid = str(r["kind"]), r["packet_id"]
|
||||
itp = f"itp-{r['itp_id']}" if r.get("itp_id") is not None else None
|
||||
if kind == "report-pdf":
|
||||
return [(None, "report", None)]
|
||||
if kind == "itp-pdf":
|
||||
return [(itp, "itp-pdf", None)]
|
||||
if kind == "itp":
|
||||
return [(itp, "print:itp", None)]
|
||||
if kind == "databook-export":
|
||||
seals: list[str | None] = [
|
||||
bytes(x[0]).hex()
|
||||
for x in conn.execute(
|
||||
"SELECT hash FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (pid,)
|
||||
).fetchall()
|
||||
]
|
||||
return [(pid, "databook", s) for s in [*seals, None]]
|
||||
if kind == "traveler":
|
||||
return [(pid, "traveler", None), (pid, "print:traveler", None)]
|
||||
return [(pid, f"print:{kind}", None)]
|
||||
|
||||
|
||||
def _minutes(r: dict[str, Any]) -> list[str]:
|
||||
out: list[str] = []
|
||||
m = RX_UTC.search(str(r.get("stamp") or ""))
|
||||
if m:
|
||||
out.append(f"{m[1]}T{m[2]}Z")
|
||||
at = r["at"].astimezone(UTC)
|
||||
out.append(stamps.minute_of(at))
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def _print_out(conn: Any, r: dict[str, Any]) -> dict[str, Any]:
|
||||
label = None
|
||||
if r["packet_id"] is not None:
|
||||
row = conn.execute(
|
||||
"SELECT pr.number, u.lot, p.drawing FROM packets p JOIN units u ON u.id = p.unit_id"
|
||||
" JOIN projects pr ON pr.id = u.project_id WHERE p.id = %s",
|
||||
(r["packet_id"],),
|
||||
).fetchone()
|
||||
if row:
|
||||
label = " ".join(x for x in (row[0], f"Lot {row[1]}" if row[1] else "", row[2] or "") if x)
|
||||
return {
|
||||
"id": r["id"],
|
||||
"at": r["at"],
|
||||
"by_name": r["by_name"],
|
||||
"kind": r["kind"],
|
||||
"what": r["what"],
|
||||
"pages": r["pages"],
|
||||
"packet_id": r["packet_id"],
|
||||
"packet_label": label,
|
||||
}
|
||||
|
||||
|
||||
def check_stamp(conn: Any, key: bytes | None, sig: str | None, shown: str | None) -> dict[str, Any]:
|
||||
"""One stamp: the signature, and the text shown up to and including it (None when only the hex was given)."""
|
||||
if not sig:
|
||||
return {"found": False, "valid": False, "reason": "No stamp signature in the text.", "print": None}
|
||||
sig = sig.lower()
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = cur.execute("SELECT * FROM print_log WHERE stamp_sig = %s ORDER BY id", (sig,)).fetchall()
|
||||
if not rows:
|
||||
return {
|
||||
"found": False,
|
||||
"valid": False,
|
||||
"reason": f"No print has stamp {sig}. The copy is not from Packet Station, or the stamp was changed.",
|
||||
"print": None,
|
||||
}
|
||||
r = rows[0]
|
||||
out = {"found": True, "valid": False, "reason": "", "print": _print_out(conn, r)}
|
||||
if key is None:
|
||||
out["reason"] = "The seal key is not available, so the signature cannot be checked."
|
||||
return out
|
||||
signed = any(
|
||||
stamps.verify_stamp(key, pid, kind, seal, r["by"], minute, sig)
|
||||
for pid, kind, seal in _sign_candidates(conn, r)
|
||||
for minute in _minutes(r)
|
||||
)
|
||||
if not signed:
|
||||
out["reason"] = "The signature does not match the print log. Treat the copy as not controlled."
|
||||
return out
|
||||
if shown is not None and r.get("stamp"):
|
||||
logged, seen = _squash(str(r["stamp"])), _squash(shown)
|
||||
if not (logged.endswith(seen) or seen.endswith(logged)):
|
||||
out["reason"] = "The stamp text differs from the print log. The copy was changed."
|
||||
return out
|
||||
out["valid"] = True
|
||||
out["reason"] = "The stamp matches the print log."
|
||||
return out
|
||||
|
||||
|
||||
def _seal_key(request: Request) -> bytes | None:
|
||||
try:
|
||||
return settings_of(request).seal_key()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _audit_verify(conn: Any, p: Principal, results: list[dict[str, Any]], source: str) -> None:
|
||||
audit.append(
|
||||
conn,
|
||||
action="admin.stamp_verified",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="print",
|
||||
subject_id=",".join(str((x["print"] or {}).get("id", "")) for x in results)[:300] or None,
|
||||
detail={
|
||||
"source": source,
|
||||
"results": [
|
||||
{"found": x["found"], "valid": x["valid"], "print_id": (x["print"] or {}).get("id")} for x in results
|
||||
],
|
||||
},
|
||||
ip=p.ip,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stamp-verify", dependencies=WRITE)
|
||||
def stamp_verify(
|
||||
body: StampText,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
text = body.text
|
||||
sig: str | None = None
|
||||
shown: str | None = None
|
||||
m = RX_SIG_IN_TEXT.search(text)
|
||||
if m is not None:
|
||||
sig, shown = m[1], text[: m.end()]
|
||||
else:
|
||||
alone = RX_SIG_ALONE.match(text)
|
||||
if alone:
|
||||
sig = alone[1]
|
||||
res = check_stamp(d.conn, _seal_key(request), sig, shown)
|
||||
_audit_verify(d.conn, p, [res], "text")
|
||||
return res
|
||||
|
||||
|
||||
def pdf_stamps(data: bytes) -> list[tuple[str, str]]:
|
||||
"""(signature, the page text up to and including it) for every stamp in a PDF's text, first page first."""
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
found: dict[str, str] = {}
|
||||
for page in reader.pages:
|
||||
text = page.extract_text() or ""
|
||||
for m in RX_SIG_IN_TEXT.finditer(text):
|
||||
sig = m[1].lower()
|
||||
if sig not in found:
|
||||
found[sig] = text[: m.end()]
|
||||
if len(found) >= PDF_STAMPS_MAX:
|
||||
break
|
||||
except (PdfReadError, ValueError, KeyError, TypeError) as e:
|
||||
raise ApiError(422, "bad_pdf", f"The PDF cannot be read: {type(e).__name__}.") from None
|
||||
return list(found.items())
|
||||
|
||||
|
||||
@router.post("/stamp-verify/pdf")
|
||||
async def stamp_verify_pdf(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
length = request.headers.get("content-length")
|
||||
if length is not None and length.isdigit() and int(length) > PDF_MAX_BYTES + FORM_SLACK:
|
||||
raise ApiError(413, "too_large", "The PDF is larger than 25 MB.")
|
||||
form = await request.form(max_files=1, max_fields=2)
|
||||
try:
|
||||
file = form.get("file")
|
||||
if not isinstance(file, UploadFile):
|
||||
raise ApiError(422, "invalid_request", "Send the PDF in the 'file' field.")
|
||||
data = await file.read(PDF_MAX_BYTES + 1)
|
||||
finally:
|
||||
await form.close()
|
||||
if len(data) > PDF_MAX_BYTES:
|
||||
raise ApiError(413, "too_large", "The PDF is larger than 25 MB.")
|
||||
if not data.startswith(b"%PDF-"):
|
||||
raise ApiError(422, "not_pdf", "The file is not a PDF.")
|
||||
found = await run_in_threadpool(pdf_stamps, data)
|
||||
key = _seal_key(request)
|
||||
|
||||
def check_all() -> list[dict[str, Any]]:
|
||||
results = [check_stamp(d.conn, key, sig, shown) for sig, shown in found]
|
||||
_audit_verify(d.conn, p, results, "pdf")
|
||||
return results
|
||||
|
||||
return {"stamps": await run_in_threadpool(check_all)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- directory flags
|
||||
|
||||
|
||||
@router.get("/directory-flags")
|
||||
def directory_flags(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
|
||||
conn = d.conn
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
run = cur.execute(
|
||||
"SELECT finished_at FROM job_runs WHERE job = 'ad-reconcile' AND finished_at IS NOT NULL"
|
||||
" ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
rows = cur.execute(
|
||||
"SELECT id, sam_account, display_name, emp_no, directory_flags FROM users WHERE active"
|
||||
" AND (coalesce((directory_flags->>'emp_missing')::bool, false)"
|
||||
" OR coalesce((directory_flags->>'emp_duplicate')::bool, false))"
|
||||
" ORDER BY emp_no NULLS FIRST, lower(display_name)"
|
||||
).fetchall()
|
||||
synced = cur.execute("SELECT max(ad_synced_at) AS at FROM users").fetchone()
|
||||
missing: list[dict[str, Any]] = []
|
||||
dupes: dict[str, list[dict[str, Any]]] = {}
|
||||
for r in rows:
|
||||
who = {"user_id": str(r["id"]), "sam_account": r["sam_account"], "display_name": r["display_name"]}
|
||||
flags = r["directory_flags"] or {}
|
||||
if flags.get("emp_missing"):
|
||||
missing.append(who)
|
||||
if flags.get("emp_duplicate") and r["emp_no"]:
|
||||
dupes.setdefault(str(r["emp_no"]), []).append(who)
|
||||
return {
|
||||
"checked_at": (run or {}).get("finished_at") or (synced or {}).get("at"),
|
||||
"missing": missing,
|
||||
"duplicate": [{"emp_no": k, "users": v} for k, v in sorted(dupes.items())],
|
||||
}
|
||||
@@ -15,7 +15,7 @@ from fastapi import Depends, Request, Response
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import audit, db, events
|
||||
from ..auth import devices, sessions
|
||||
from ..auth import devices, secsettings, sessions
|
||||
from ..auth import directory as userdir
|
||||
from ..auth import groups as groupmap
|
||||
from ..auth.ldap import Directory, DirectoryProtocol, LdapUnavailable
|
||||
@@ -48,6 +48,15 @@ def settings_of(request: Request) -> Settings:
|
||||
return s
|
||||
|
||||
|
||||
def effective_settings(request: Request, conn: Any) -> Settings:
|
||||
"""The settings with the Admin's security values applied, read once per request."""
|
||||
s: Settings | None = getattr(request.state, "sec_settings", None)
|
||||
if s is None:
|
||||
s = secsettings.overlay(conn, settings_of(request))
|
||||
request.state.sec_settings = s
|
||||
return s
|
||||
|
||||
|
||||
def directory_of(request: Request) -> DirectoryProtocol:
|
||||
d: DirectoryProtocol | None = request.app.state.directory
|
||||
if d is None:
|
||||
@@ -257,9 +266,9 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
|
||||
token = token or request.cookies.get(sessions.SESSION_COOKIE)
|
||||
if not token:
|
||||
raise ApiError(401, "no_session", "Not signed in.")
|
||||
s = settings_of(request)
|
||||
key = session_key(request)
|
||||
conn = d.conn
|
||||
s = effective_settings(request, conn)
|
||||
ip = ip_of(request)
|
||||
row = sessions.load(conn, key, token)
|
||||
if row is None or row["revoked_at"] is not None or row["expires_at"] <= row["db_now"]:
|
||||
@@ -312,7 +321,7 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
|
||||
if now - row["roles_checked_at"] > timedelta(minutes=s.roles_refresh_minutes):
|
||||
roles, groups = _refresh_roles(conn, directory_of(request), row, ip)
|
||||
device = devices.get(conn, row["device_id"]) if row.get("device_id") else None
|
||||
return Principal(
|
||||
p = Principal(
|
||||
session=row,
|
||||
user=user,
|
||||
roles=roles,
|
||||
@@ -324,6 +333,9 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
|
||||
ip=ip,
|
||||
lock_minutes=idle_minutes(s, roles)[1],
|
||||
)
|
||||
# The error handler audits a 403 against the principal it was refused to (policy.denied)
|
||||
request.state.principal = p
|
||||
return p
|
||||
|
||||
|
||||
def current_principal(request: Request, d: Db = Depends(get_db)) -> Principal: # noqa: B008
|
||||
|
||||
@@ -49,7 +49,22 @@ def error_response(err: ApiError) -> JSONResponse:
|
||||
|
||||
def install(app: FastAPI) -> None:
|
||||
@app.exception_handler(ApiError)
|
||||
async def _api_error(_: Request, exc: ApiError) -> JSONResponse:
|
||||
async def _api_error(request: Request, exc: ApiError) -> JSONResponse:
|
||||
principal = getattr(request.state, "principal", None)
|
||||
if exc.status == 403 and principal is not None:
|
||||
# A refusal to a signed-in person is audited as policy.denied: method, path and code, never the body
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..domain.security_alerts import record_denied
|
||||
|
||||
await run_in_threadpool(
|
||||
record_denied,
|
||||
request.app.state.settings,
|
||||
principal,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
code=exc.code,
|
||||
)
|
||||
return error_response(exc)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
|
||||
@@ -493,6 +493,7 @@ def reject_line(
|
||||
)
|
||||
head = f"NCR {ncr}" if ncr else "NCR # pending"
|
||||
w.log(f"ITP line {key} rejected", f"{head} · {note}" if note else head)
|
||||
dom.notify_reject(w, key, ncr, note)
|
||||
return _answer(d, p, itp_id)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Notifications: the Admin's routing rules, test send and sent log, and each person's own view on Account.
|
||||
|
||||
Who gets what lives in the database (notification_rules, _recipients, _exclusions), never in the environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import time
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from psycopg.rows import dict_row
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .. import audit
|
||||
from ..auth.ldap import LdapUnavailable
|
||||
from ..notify import seed as seed_rules
|
||||
from ..notify import send as notify_send
|
||||
from ..notify import smtp
|
||||
from ..notify.catalog import EVENT_TYPES, SECURITY_TYPES
|
||||
from ..notify.routing import (
|
||||
DIRECTORY_DOWN,
|
||||
NotifyDirectory,
|
||||
Rule,
|
||||
address_ok,
|
||||
cn_of,
|
||||
dn_under,
|
||||
load_rules,
|
||||
supervisor_depts,
|
||||
)
|
||||
from ..settings import Settings
|
||||
from ..text import clean_text
|
||||
from .deps import Db, Principal, admin_principal, current_principal, directory_of, get_db, password_principal
|
||||
from .errors import ApiError
|
||||
from .packets import WRITE
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["notifications"])
|
||||
|
||||
ADMIN = "/api/admin/notifications"
|
||||
ACCOUNT = "/api/account/notifications"
|
||||
RECIPIENTS_MAX = 50
|
||||
EXCLUSIONS_MAX = 200
|
||||
TIME_RE = re.compile(r"^([01][0-9]|2[0-3]):([0-5][0-9])$")
|
||||
NAME_MAX = 256
|
||||
DN_MAX = 1024
|
||||
|
||||
|
||||
def _directory(request: Request) -> NotifyDirectory:
|
||||
return cast(NotifyDirectory, directory_of(request))
|
||||
|
||||
|
||||
def _settings(request: Request) -> Settings:
|
||||
s: Settings = request.app.state.settings
|
||||
return s
|
||||
|
||||
|
||||
def _known(event_type: str) -> None:
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ApiError(404, "not_found", "No such notification.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- views
|
||||
|
||||
|
||||
def rule_view(conn: Any, rule: Rule) -> dict[str, Any]:
|
||||
et = EVENT_TYPES[rule.event_type]
|
||||
last = conn.execute(
|
||||
"SELECT at, result FROM notifications_sent WHERE event_type = %s ORDER BY id DESC LIMIT 1",
|
||||
(rule.event_type,),
|
||||
).fetchone()
|
||||
return {
|
||||
"event_type": rule.event_type,
|
||||
"label": et.label,
|
||||
"description": et.description,
|
||||
"security": et.security,
|
||||
"dept_scoped": et.dept_scoped,
|
||||
"param": {"name": et.param, "value": rule.param_value, "default": et.param_default} if et.param else None,
|
||||
"enabled": rule.enabled,
|
||||
"delivery": rule.delivery,
|
||||
"digest_time": rule.digest_time.strftime("%H:%M"),
|
||||
"recipients": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"kind": r["kind"],
|
||||
"group_dn": r["group_dn"],
|
||||
"group_name": r["group_name"],
|
||||
"user_id": str(r["user_id"]) if r["user_id"] else None,
|
||||
"display_name": r.get("display_name"),
|
||||
"address": r["address"],
|
||||
}
|
||||
for r in rule.recipients
|
||||
],
|
||||
"exclusions": [
|
||||
{"user_id": str(x["user_id"]), "display_name": x.get("display_name"), "self": bool(x["self"])}
|
||||
for x in rule.exclusions
|
||||
],
|
||||
"version": rule.version,
|
||||
"last_sent": {"at": last[0], "result": last[1]} if last else None,
|
||||
}
|
||||
|
||||
|
||||
def _rules(conn: Any, settings: Settings) -> dict[str, Rule]:
|
||||
seed_rules(conn, settings)
|
||||
return load_rules(conn)
|
||||
|
||||
|
||||
@router.get(ADMIN)
|
||||
def admin_rules(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
s = _settings(request)
|
||||
rules = _rules(d.conn, s)
|
||||
return {
|
||||
"dry_run": bool(s.notify_dry_run),
|
||||
"smtp": {"host": s.smtp_host, "port": s.smtp_port, "from": s.smtp_from, "starttls": s.smtp_starttls},
|
||||
"subject_prefix": smtp.subject_prefix(s),
|
||||
"rules": [rule_view(d.conn, rules[k]) for k in EVENT_TYPES if k in rules],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- update a rule
|
||||
|
||||
|
||||
class RecipientIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: Literal["group", "user", "mailbox", "dept_supervisor"]
|
||||
group_dn: str | None = Field(default=None, max_length=DN_MAX)
|
||||
group_name: str | None = Field(default=None, max_length=NAME_MAX)
|
||||
user_id: uuid.UUID | None = None
|
||||
address: str | None = Field(default=None, max_length=254)
|
||||
|
||||
|
||||
class RuleIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool
|
||||
delivery: Literal["immediate", "digest"]
|
||||
digest_time: str = Field(max_length=5)
|
||||
param: int | None = Field(default=None, ge=1, le=10000)
|
||||
recipients: list[RecipientIn] = Field(max_length=RECIPIENTS_MAX)
|
||||
exclusions: list[uuid.UUID] = Field(default_factory=list, max_length=EXCLUSIONS_MAX)
|
||||
version: int
|
||||
|
||||
|
||||
def _invalid(text: str) -> ApiError:
|
||||
return ApiError(422, "invalid_request", text)
|
||||
|
||||
|
||||
def _clean_recipients(conn: Any, s: Settings, event_type: str, items: list[RecipientIn]) -> list[dict[str, Any]]:
|
||||
et = EVENT_TYPES[event_type]
|
||||
base = s.ldap_base_dn.strip() or s.ldap_group_base_dn.strip()
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for r in items:
|
||||
row: dict[str, Any] = {"kind": r.kind, "group_dn": None, "group_name": None, "user_id": None, "address": None}
|
||||
if r.kind == "group":
|
||||
dn = clean_text(r.group_dn or "", DN_MAX)
|
||||
if not dn or not dn.lower().startswith("cn="):
|
||||
raise _invalid("A group needs its DN.")
|
||||
if base and not dn_under(dn, base):
|
||||
raise _invalid("The group must be in the domain (under LDAP_BASE_DN).")
|
||||
row["group_dn"] = dn
|
||||
row["group_name"] = clean_text(r.group_name or "", NAME_MAX) or cn_of(dn)
|
||||
key = ("group", dn.lower())
|
||||
elif r.kind == "user":
|
||||
if r.user_id is None:
|
||||
raise _invalid("A user recipient needs a user.")
|
||||
if conn.execute("SELECT 1 FROM users WHERE id = %s", (r.user_id,)).fetchone() is None:
|
||||
raise _invalid("No such user.")
|
||||
row["user_id"] = r.user_id
|
||||
key = ("user", str(r.user_id))
|
||||
elif r.kind == "mailbox":
|
||||
addr = (r.address or "").strip()
|
||||
if not address_ok(addr):
|
||||
raise _invalid(f"Not an email address: {addr[:80]}")
|
||||
row["address"] = addr
|
||||
key = ("mailbox", addr.lower())
|
||||
else:
|
||||
if not et.dept_scoped:
|
||||
raise _invalid("This notification has no department, so it has no Supervisor group.")
|
||||
key = ("dept_supervisor", "")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
@router.put(f"{ADMIN}/{{event_type}}", dependencies=WRITE)
|
||||
def update_rule(
|
||||
event_type: str,
|
||||
body: RuleIn,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
_known(event_type)
|
||||
s = _settings(request)
|
||||
conn = d.conn
|
||||
et = EVENT_TYPES[event_type]
|
||||
m = TIME_RE.match(body.digest_time)
|
||||
if m is None:
|
||||
raise _invalid("The digest time is HH:MM.")
|
||||
digest_at = time(int(m.group(1)), int(m.group(2)))
|
||||
if body.param is not None and et.param is None:
|
||||
raise _invalid("This notification takes no setting.")
|
||||
excl = list(dict.fromkeys(body.exclusions))
|
||||
if excl and event_type in SECURITY_TYPES:
|
||||
raise _invalid("Nobody can be left out of a security notification.")
|
||||
recipients = _clean_recipients(conn, s, event_type, body.recipients)
|
||||
if excl:
|
||||
found = {r[0] for r in conn.execute("SELECT id FROM users WHERE id = ANY(%s)", (excl,)).fetchall()}
|
||||
if len(found) != len(excl):
|
||||
raise _invalid("An excluded person is not a known user.")
|
||||
_rules(conn, s)
|
||||
with conn.transaction():
|
||||
before_rule = load_rules(conn, event_type).get(event_type)
|
||||
assert before_rule is not None
|
||||
before = rule_view(conn, before_rule)
|
||||
done = conn.execute(
|
||||
"UPDATE notification_rules SET enabled = %s, delivery = %s, digest_time = %s, param = %s,"
|
||||
" version = version + 1, updated_at = now(), updated_by = %s WHERE event_type = %s AND version = %s"
|
||||
" RETURNING version",
|
||||
(
|
||||
body.enabled,
|
||||
body.delivery,
|
||||
digest_at,
|
||||
(body.param or et.param_default) if et.param else None,
|
||||
p.user_id,
|
||||
event_type,
|
||||
body.version,
|
||||
),
|
||||
).fetchone()
|
||||
if done is None:
|
||||
raise ApiError(
|
||||
409,
|
||||
"version_conflict",
|
||||
"Someone else changed this notification. Here is the current one.",
|
||||
extra={"current": before},
|
||||
)
|
||||
conn.execute("DELETE FROM notification_recipients WHERE event_type = %s", (event_type,))
|
||||
for r in recipients:
|
||||
conn.execute(
|
||||
"INSERT INTO notification_recipients (event_type, kind, group_dn, group_name, user_id, address)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
(event_type, r["kind"], r["group_dn"], r["group_name"], r["user_id"], r["address"]),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM notification_exclusions WHERE event_type = %s AND NOT self AND NOT (user_id = ANY(%s))",
|
||||
(event_type, excl),
|
||||
)
|
||||
for uid in excl:
|
||||
conn.execute(
|
||||
"INSERT INTO notification_exclusions (event_type, user_id, self, created_by) VALUES (%s, %s, false, %s)"
|
||||
" ON CONFLICT (event_type, user_id) DO NOTHING",
|
||||
(event_type, uid, p.user_id),
|
||||
)
|
||||
after_rule = load_rules(conn, event_type)[event_type]
|
||||
after = rule_view(conn, after_rule)
|
||||
audit.append(
|
||||
conn,
|
||||
action="admin.notification_rule_updated",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="notification_rule",
|
||||
subject_id=event_type,
|
||||
detail={"event_type": event_type, "before": _audit_view(before), "after": _audit_view(after)},
|
||||
ip=p.ip,
|
||||
)
|
||||
return after
|
||||
|
||||
|
||||
def _audit_view(v: dict[str, Any]) -> dict[str, Any]:
|
||||
keep = ("enabled", "delivery", "digest_time", "param", "version")
|
||||
out = {k: v[k] for k in keep}
|
||||
out["recipients"] = [
|
||||
{k: r[k] for k in ("kind", "group_dn", "group_name", "user_id", "address") if r.get(k)} for r in v["recipients"]
|
||||
]
|
||||
out["exclusions"] = [{"user_id": x["user_id"], "self": x["self"]} for x in v["exclusions"]]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- group search, test send, sent log
|
||||
|
||||
|
||||
@router.get(f"{ADMIN}/groups")
|
||||
def search_groups(
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
q: str = Query(..., min_length=2, max_length=100),
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
found = _directory(request).search_groups(q.strip(), 25)
|
||||
except LdapUnavailable as e:
|
||||
log.warning("group search: AD unavailable: %s", e)
|
||||
raise ApiError(503, "ldap_unavailable", "The directory is not available. Try again in a minute.") from None
|
||||
return [{"dn": g.get("dn"), "name": g.get("name"), "description": g.get("description")} for g in found]
|
||||
|
||||
|
||||
class TestIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
to: str | None = Field(default=None, max_length=254)
|
||||
|
||||
|
||||
@router.post(f"{ADMIN}/{{event_type}}/test", dependencies=WRITE)
|
||||
def test_send(
|
||||
event_type: str,
|
||||
body: TestIn,
|
||||
request: Request,
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
_known(event_type)
|
||||
s = _settings(request)
|
||||
to = (body.to or "").strip() or None
|
||||
if to is not None and not address_ok(to):
|
||||
raise _invalid("Not an email address.")
|
||||
conn = d.conn
|
||||
_rules(conn, s)
|
||||
out = notify_send.send_test(conn, s, _directory(request), event_type, to=to, requested_by=p.user_id)
|
||||
audit.append(
|
||||
conn,
|
||||
action="admin.notification_test",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="notification_rule",
|
||||
subject_id=event_type,
|
||||
detail={
|
||||
"event_type": event_type,
|
||||
"to": to,
|
||||
"result": out["result"],
|
||||
"recipients": len(out["recipients"]),
|
||||
"sent_id": out["sent_id"],
|
||||
},
|
||||
ip=p.ip,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(f"{ADMIN}/sent")
|
||||
def sent_log(
|
||||
p: Principal = Depends(admin_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
event_type: str | None = Query(None, max_length=64),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
before: int | None = Query(None, ge=1),
|
||||
) -> dict[str, Any]:
|
||||
sql = (
|
||||
"SELECT id, at, event_type, digest, test, subject, recipients, result, error FROM notifications_sent"
|
||||
" WHERE (%s::text IS NULL OR event_type = %s) AND (%s::bigint IS NULL OR id < %s) ORDER BY id DESC LIMIT %s"
|
||||
)
|
||||
args = (event_type or None, event_type or None, before, before)
|
||||
with d.conn.cursor(row_factory=dict_row) as cur:
|
||||
rows = [dict(r) for r in cur.execute(sql, (*args, limit)).fetchall()]
|
||||
return {"rows": rows, "next_before": rows[-1]["id"] if len(rows) == limit else None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- account
|
||||
|
||||
|
||||
def _my_groups(directory: NotifyDirectory, user_id: uuid.UUID) -> tuple[Any, set[str]]:
|
||||
"""(the AdUser or None, lower-case names of the user's groups in the PacketStation OU, nested)."""
|
||||
u = directory.get_user_by_guid(user_id)
|
||||
if u is None:
|
||||
return None, set()
|
||||
return u, {g.lower() for g in directory.groups_of(u.dn)}
|
||||
|
||||
|
||||
def account_view(conn: Any, settings: Settings, directory: NotifyDirectory, p: Principal) -> list[dict[str, Any]]:
|
||||
rules = _rules(conn, settings)
|
||||
email = str(p.user.get("email") or "").strip().lower()
|
||||
me = str(p.user_id)
|
||||
sup = supervisor_depts(p.roles)
|
||||
groups: tuple[Any, set[str]] | None = None
|
||||
down = False
|
||||
member_cache: dict[str, bool] = {}
|
||||
out: list[dict[str, Any]] = []
|
||||
for key, et in EVENT_TYPES.items():
|
||||
rule = rules.get(key)
|
||||
if rule is None or not rule.enabled:
|
||||
continue
|
||||
why: list[str] = []
|
||||
for r in rule.recipients:
|
||||
kind = r["kind"]
|
||||
if kind == "user" and str(r["user_id"]) == me:
|
||||
why.append("user")
|
||||
elif kind == "mailbox" and email and str(r["address"]).strip().lower() == email:
|
||||
why.append("mailbox")
|
||||
elif kind == "dept_supervisor" and et.dept_scoped and sup:
|
||||
why.extend(f"Supervisor group of {code}" for code in sup)
|
||||
elif kind == "group" and not down:
|
||||
dn = str(r["group_dn"])
|
||||
name = str(r["group_name"] or cn_of(dn))
|
||||
try:
|
||||
if dn_under(dn, settings.ldap_group_base_dn):
|
||||
if groups is None:
|
||||
groups = _my_groups(directory, p.user_id)
|
||||
hit = name.lower() in groups[1]
|
||||
else:
|
||||
if dn.lower() not in member_cache:
|
||||
member_cache[dn.lower()] = any(
|
||||
u.guid == p.user_id for u in directory.members_of_dn(dn) if not u.disabled
|
||||
)
|
||||
hit = member_cache[dn.lower()]
|
||||
except Exception as e:
|
||||
log.warning("account notifications: directory: %s", type(e).__name__)
|
||||
down = True
|
||||
continue
|
||||
if hit:
|
||||
why.append(f"group {name}")
|
||||
excl = next((x for x in rule.exclusions if str(x["user_id"]) == me), None)
|
||||
opted_out = bool(excl and excl["self"])
|
||||
if excl and not excl["self"]:
|
||||
why.append("left out by an Admin")
|
||||
if down and any(r["kind"] == "group" for r in rule.recipients):
|
||||
why.append(DIRECTORY_DOWN)
|
||||
reaches = excl is None and any(w not in (DIRECTORY_DOWN, "left out by an Admin") for w in why)
|
||||
out.append(
|
||||
{
|
||||
"event_type": key,
|
||||
"label": et.label,
|
||||
"security": et.security,
|
||||
"delivery": rule.delivery,
|
||||
"reaches": reaches,
|
||||
"why": list(dict.fromkeys(why)),
|
||||
"opted_out": opted_out,
|
||||
"can_opt_out": not et.security,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(ACCOUNT)
|
||||
def my_notifications(
|
||||
request: Request,
|
||||
p: Principal = Depends(current_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> list[dict[str, Any]]:
|
||||
return account_view(d.conn, _settings(request), _directory(request), p)
|
||||
|
||||
|
||||
class OptOutIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
opt_out: bool
|
||||
|
||||
|
||||
@router.put(f"{ACCOUNT}/{{event_type}}", dependencies=WRITE)
|
||||
def opt_out(
|
||||
event_type: str,
|
||||
body: OptOutIn,
|
||||
request: Request,
|
||||
p: Principal = Depends(password_principal), # noqa: B008
|
||||
d: Db = Depends(get_db), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
_known(event_type)
|
||||
if event_type in SECURITY_TYPES:
|
||||
raise ApiError(403, "forbidden", "Security notifications cannot be turned off.")
|
||||
conn = d.conn
|
||||
s = _settings(request)
|
||||
_rules(conn, s)
|
||||
with conn.transaction():
|
||||
if body.opt_out:
|
||||
changed = conn.execute(
|
||||
"INSERT INTO notification_exclusions (event_type, user_id, self, created_by) VALUES (%s, %s, true, %s)"
|
||||
" ON CONFLICT (event_type, user_id) DO NOTHING",
|
||||
(event_type, p.user_id, p.user_id),
|
||||
).rowcount
|
||||
else:
|
||||
changed = conn.execute(
|
||||
"DELETE FROM notification_exclusions WHERE event_type = %s AND user_id = %s AND self",
|
||||
(event_type, p.user_id),
|
||||
).rowcount
|
||||
if changed:
|
||||
audit.append(
|
||||
conn,
|
||||
action="account.notification_opt_out",
|
||||
user_id=p.user_id,
|
||||
session_id=p.session_id,
|
||||
role_snapshot=p.role_snapshot,
|
||||
subject_type="notification_rule",
|
||||
subject_id=event_type,
|
||||
detail={"event_type": event_type, "opt_out": body.opt_out},
|
||||
ip=p.ip,
|
||||
)
|
||||
item = next((x for x in account_view(conn, s, _directory(request), p) if x["event_type"] == event_type), None)
|
||||
if item is None:
|
||||
# The rule is off: nothing reaches anyone, but the choice is kept
|
||||
et = EVENT_TYPES[event_type]
|
||||
item = {
|
||||
"event_type": event_type,
|
||||
"label": et.label,
|
||||
"security": False,
|
||||
"delivery": None,
|
||||
"reaches": False,
|
||||
"why": [],
|
||||
"opted_out": body.opt_out,
|
||||
"can_opt_out": True,
|
||||
}
|
||||
return item
|
||||
Reference in New Issue
Block a user