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