223 lines
8.2 KiB
Python
223 lines
8.2 KiB
Python
"""Rules and recipients. Recipients are resolved when a message goes, so a group stays current.
|
|||
|
|
|
||
|
|
group: the members of an AD group by DN (nested, enabled accounts with mail). user: users.email of an active user.
|
||
|
|
mailbox: the address. dept_supervisor: the Supervisor group of the event's department, none without one.
|
||
|
|
Exclusions drop a person by user id and by that person's email.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
import uuid
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from datetime import time
|
||
|
|
from typing import Any, Protocol
|
||
|
|
|
||
|
|
from psycopg.rows import dict_row
|
||
|
|
|
||
|
|
from ..auth import groups as groupmap
|
||
|
|
from ..auth.ldap import AdUser
|
||
|
|
from ..settings import Settings
|
||
|
|
from .catalog import EVENT_TYPES
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
ADDRESS_RE = re.compile(r"^[^@\s<>,;\"']{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251})\.[A-Za-z]{2,63}$")
|
||
|
|
DIRECTORY_DOWN = "directory unavailable"
|
||
|
|
|
||
|
|
|
||
|
|
class NotifyDirectory(Protocol):
|
||
|
|
"""The directory calls notifications use (a subset of auth.ldap.DirectoryProtocol)."""
|
||
|
|
|
||
|
|
def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None: ...
|
||
|
|
def groups_of(self, dn: str) -> list[str]: ...
|
||
|
|
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
|
||
|
|
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, Any]]: ...
|
||
|
|
|
||
|
|
|
||
|
|
class DirectoryDown(Exception):
|
||
|
|
"""AD could not be read while resolving a group."""
|
||
|
|
|
||
|
|
|
||
|
|
def address_ok(value: str) -> bool:
|
||
|
|
v = value.strip()
|
||
|
|
return len(v) <= 254 and ADDRESS_RE.match(v) is not None
|
||
|
|
|
||
|
|
|
||
|
|
def cn_of(dn: str) -> str:
|
||
|
|
"""The first CN of a DN ("CN=PS-Admins,OU=..." -> "PS-Admins"); the DN itself when it has none."""
|
||
|
|
first = dn.split(",", 1)[0].strip()
|
||
|
|
return first[3:].strip() if first[:3].lower() == "cn=" else dn
|
||
|
|
|
||
|
|
|
||
|
|
def dn_under(dn: str, base: str) -> bool:
|
||
|
|
b = base.strip().lower().replace(" ", "")
|
||
|
|
return bool(b) and dn.strip().lower().replace(" ", "").endswith("," + b)
|
||
|
|
|
||
|
|
|
||
|
|
def supervisor_group(dept_code: str | None) -> str | None:
|
||
|
|
"""The Supervisor group name of a department, from the group map (None when it has none)."""
|
||
|
|
if not dept_code:
|
||
|
|
return None
|
||
|
|
for name, role in groupmap.current().entries:
|
||
|
|
if role.dept_code == dept_code and role.kind.endswith("supervisor"):
|
||
|
|
return name
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def group_dn(settings: Settings, name: str) -> str:
|
||
|
|
base = settings.ldap_group_base_dn.strip()
|
||
|
|
return f"CN={name},{base}" if base else f"CN={name}"
|
||
|
|
|
||
|
|
|
||
|
|
def supervisor_depts(roles: list[str]) -> list[str]:
|
||
|
|
"""The departments a role set supervises (their Supervisor group), sorted."""
|
||
|
|
out: set[str] = set()
|
||
|
|
for text in roles:
|
||
|
|
try:
|
||
|
|
r = groupmap.parse_role(text)
|
||
|
|
except ValueError:
|
||
|
|
continue
|
||
|
|
if r.dept_code and r.kind.endswith("supervisor"):
|
||
|
|
out.add(r.dept_code)
|
||
|
|
return sorted(out)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- rules
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Rule:
|
||
|
|
event_type: str
|
||
|
|
enabled: bool
|
||
|
|
delivery: str
|
||
|
|
digest_time: time
|
||
|
|
param: int | None
|
||
|
|
version: int
|
||
|
|
recipients: list[dict[str, Any]] = field(default_factory=list)
|
||
|
|
exclusions: list[dict[str, Any]] = field(default_factory=list)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def param_value(self) -> int | None:
|
||
|
|
et = EVENT_TYPES.get(self.event_type)
|
||
|
|
if et is None or et.param is None:
|
||
|
|
return None
|
||
|
|
return self.param if self.param is not None else et.param_default
|
||
|
|
|
||
|
|
|
||
|
|
def load_rules(conn: Any, event_type: str | None = None) -> dict[str, Rule]:
|
||
|
|
args = (event_type, event_type)
|
||
|
|
with conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
rules = {
|
||
|
|
r["event_type"]: Rule(
|
||
|
|
r["event_type"],
|
||
|
|
bool(r["enabled"]),
|
||
|
|
str(r["delivery"]),
|
||
|
|
r["digest_time"],
|
||
|
|
r["param"],
|
||
|
|
int(r["version"]),
|
||
|
|
)
|
||
|
|
for r in cur.execute(
|
||
|
|
"SELECT event_type, enabled, delivery::text AS delivery, digest_time, param, version"
|
||
|
|
" FROM notification_rules WHERE %s::text IS NULL OR event_type = %s",
|
||
|
|
args,
|
||
|
|
).fetchall()
|
||
|
|
}
|
||
|
|
for r in cur.execute(
|
||
|
|
"SELECT r.id, r.event_type, r.kind::text AS kind, r.group_dn, r.group_name, r.user_id, r.address,"
|
||
|
|
" u.display_name FROM notification_recipients r LEFT JOIN users u ON u.id = r.user_id"
|
||
|
|
" WHERE %s::text IS NULL OR r.event_type = %s ORDER BY r.id",
|
||
|
|
args,
|
||
|
|
).fetchall():
|
||
|
|
if r["event_type"] in rules:
|
||
|
|
rules[r["event_type"]].recipients.append(dict(r))
|
||
|
|
for r in cur.execute(
|
||
|
|
"SELECT x.event_type, x.user_id, x.self, u.display_name, u.email FROM notification_exclusions x"
|
||
|
|
" LEFT JOIN users u ON u.id = x.user_id WHERE %s::text IS NULL OR x.event_type = %s"
|
||
|
|
" ORDER BY lower(u.display_name), x.user_id",
|
||
|
|
args,
|
||
|
|
).fetchall():
|
||
|
|
if r["event_type"] in rules:
|
||
|
|
rules[r["event_type"]].exclusions.append(dict(r))
|
||
|
|
return rules
|
||
|
|
|
||
|
|
|
||
|
|
def load_rule(conn: Any, event_type: str) -> Rule | None:
|
||
|
|
return load_rules(conn, event_type).get(event_type)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- resolving
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Recipient:
|
||
|
|
address: str
|
||
|
|
user_id: str | None
|
||
|
|
why: str
|
||
|
|
|
||
|
|
def as_json(self) -> dict[str, Any]:
|
||
|
|
return {"address": self.address, "user_id": self.user_id, "why": self.why}
|
||
|
|
|
||
|
|
|
||
|
|
class Resolver:
|
||
|
|
"""Resolves rules to addresses. Caches group members for its lifetime (one run or one request)."""
|
||
|
|
|
||
|
|
def __init__(self, conn: Any, settings: Settings, directory: NotifyDirectory | None):
|
||
|
|
self.conn = conn
|
||
|
|
self.settings = settings
|
||
|
|
self.directory = directory
|
||
|
|
self._members: dict[str, list[AdUser]] = {}
|
||
|
|
|
||
|
|
def members(self, dn: str) -> list[AdUser]:
|
||
|
|
key = dn.strip().lower()
|
||
|
|
if key not in self._members:
|
||
|
|
if self.directory is None:
|
||
|
|
raise DirectoryDown(DIRECTORY_DOWN)
|
||
|
|
try:
|
||
|
|
self._members[key] = list(self.directory.members_of_dn(dn))
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("notify: can't read group %s: %s", cn_of(dn), type(e).__name__)
|
||
|
|
raise DirectoryDown(DIRECTORY_DOWN) from None
|
||
|
|
return self._members[key]
|
||
|
|
|
||
|
|
def _group(self, dn: str, why: str) -> list[Recipient]:
|
||
|
|
return [
|
||
|
|
Recipient(u.email.strip(), str(u.guid), why)
|
||
|
|
for u in self.members(dn)
|
||
|
|
if not u.disabled and u.email and u.email.strip()
|
||
|
|
]
|
||
|
|
|
||
|
|
def resolve(self, rule: Rule, dept_code: str | None) -> list[Recipient]:
|
||
|
|
"""Every address the rule reaches for an event of this department, after exclusions, one per address.
|
||
|
|
|
||
|
|
Raises DirectoryDown when a group cannot be read."""
|
||
|
|
found: list[Recipient] = []
|
||
|
|
for r in rule.recipients:
|
||
|
|
kind = r["kind"]
|
||
|
|
if kind == "group" and r.get("group_dn"):
|
||
|
|
found.extend(self._group(r["group_dn"], f"group {r.get('group_name') or cn_of(r['group_dn'])}"))
|
||
|
|
elif kind == "user" and r.get("user_id"):
|
||
|
|
row = self.conn.execute(
|
||
|
|
"SELECT email FROM users WHERE id = %s AND active AND coalesce(email, '') <> ''", (r["user_id"],)
|
||
|
|
).fetchone()
|
||
|
|
if row is not None:
|
||
|
|
found.append(Recipient(str(row[0]).strip(), str(r["user_id"]), "user"))
|
||
|
|
elif kind == "mailbox" and r.get("address"):
|
||
|
|
found.append(Recipient(str(r["address"]).strip(), None, "mailbox"))
|
||
|
|
elif kind == "dept_supervisor":
|
||
|
|
name = supervisor_group(dept_code)
|
||
|
|
if name:
|
||
|
|
found.extend(self._group(group_dn(self.settings, name), f"Supervisor group of {dept_code}"))
|
||
|
|
excluded_ids = {str(x["user_id"]) for x in rule.exclusions}
|
||
|
|
excluded_mail = {str(x["email"]).strip().lower() for x in rule.exclusions if x.get("email")}
|
||
|
|
out: list[Recipient] = []
|
||
|
|
seen: set[str] = set()
|
||
|
|
for rc in found:
|
||
|
|
key = rc.address.lower()
|
||
|
|
if key in seen or (rc.user_id and rc.user_id in excluded_ids) or key in excluded_mail:
|
||
|
|
continue
|
||
|
|
seen.add(key)
|
||
|
|
out.append(rc)
|
||
|
|
return out
|