Phase 9: Admin completion, reconcile, notifications, integrity, backups
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s

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:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+61 -12
View File
@@ -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")
+287
View File
@@ -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)
+634
View File
@@ -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 -3
View File
@@ -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
+16 -1
View File
@@ -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)
+1
View File
@@ -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)
+511
View File
@@ -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