Files

512 lines
19 KiB
Python
Raw Permalink Normal View History

"""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