"""Shared access-control model used by both the API and the web UI. Both services authenticate against the same tables (users, groups, access_rules, sessions — see db/init/002_access_control.sql), so the meaning of a rule must be defined in exactly one place: here. Semantics: - A pattern is a glob matched against the FULL topic / tag name (e.g. ``11001.KF1.BF1_Value``). ``*`` is the only wildcard and matches any character sequence, including ``.``. Everything else (``.``, ``_``, digits, ...) is literal — deliberately NOT regex: the audience is plant engineers, and globs translate to SQL LIKE (with ``_`` / ``%`` / backslash escaped) so authorization can be pushed down into telemetry queries. - A user's effective rules are the union of its personal rules and the rules of every group it belongs to. - deny wins over allow: a topic is readable only if at least one allow rule matches it AND no deny rule does. This is what lets a user rule override a group grant (via a user deny) and vice versa. - Admin users and certificate-authenticated services bypass rule evaluation and see everything. """ from __future__ import annotations import hashlib import re from dataclasses import dataclass, field from functools import lru_cache from typing import Any, Mapping, Optional import psycopg from psycopg import sql from psycopg.rows import tuple_row #: Cookie carrying the session token. Shared by the web UI (which sets #: it) and the API (which accepts it), so a web login is also an API login. COOKIE_NAME: str = "mqtt_session" # -- Pattern translation ------------------------------------------------- def escape_like(value: str) -> str: """Escape LIKE special characters so ``value`` matches literally.""" return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") def glob_to_like(pattern: str) -> str: """Translate an access-control glob to a SQL LIKE pattern. ``*`` becomes ``%``; ``_``, ``%`` and ``\\`` in the pattern are literal and must be escaped — ``11001.*_Value`` must not let ``_`` match an arbitrary character. """ if pattern == "*": return "%" return escape_like(pattern).replace("*", "%") @lru_cache(maxsize=1024) def _compile_glob(pattern: str) -> re.Pattern[str]: parts = pattern.split("*") return re.compile("^" + ".*".join(re.escape(part) for part in parts) + "$", re.DOTALL) def _glob_match(pattern: str, topic: str) -> bool: return _compile_glob(pattern).match(topic) is not None # -- Rule model ------------------------------------------------------------ @dataclass(frozen=True) class Rules: """Allow/deny glob patterns; combined user+group rules are a union.""" allow: tuple[str, ...] = () deny: tuple[str, ...] = () @property def allow_all(self) -> bool: return "*" in self.allow @property def deny_all(self) -> bool: return "*" in self.deny def allow_likes(self) -> list[str]: """LIKE patterns for the allow side (``*`` handled separately).""" return [glob_to_like(p) for p in self.allow if p != "*"] def deny_likes(self) -> list[str]: return [glob_to_like(p) for p in self.deny if p != "*"] def topic_allowed(self, topic: str) -> bool: """In-Python check for a single topic (deny wins over allow).""" if self.deny_all: return False for pattern in self.deny: if _glob_match(pattern, topic): return False if self.allow_all: return True return any(_glob_match(p, topic) for p in self.allow) @dataclass(frozen=True) class Identity: """The authenticated principal for one request.""" user_id: Optional[int] username: str role: str # 'admin' | 'user' | 'service' rules: Rules = field(default_factory=Rules) must_change_password: bool = False csrf_token: str = "" @classmethod def service(cls, peercert: Mapping[str, Any]) -> "Identity": """Identity for an mTLS-authenticated client certificate.""" common_name = "" for rdn in peercert.get("subject") or (): for attr, value in rdn: if attr == "commonName": common_name = value return cls( user_id=None, username=common_name or "client", role="service", rules=Rules(allow=("*",)), ) @property def is_admin(self) -> bool: return self.role == "admin" @property def full_access(self) -> bool: return self.role in ("admin", "service") def topic_allowed(self, topic: str) -> bool: return self.full_access or self.rules.topic_allowed(topic) def topic_conditions(identity: Identity) -> tuple[list[sql.Composable], list[list[str]]]: """WHERE fragments + params enforcing ``identity`` on telemetry.topic. Conditions are composed for psycopg.sql and ANDed into the query, so filtering happens in the database and LIMIT stays correct. Users with no allow rules match nothing (deny-by-default). """ conditions: list[sql.Composable] = [] params: list[list[str]] = [] if identity.full_access: return conditions, params rules = identity.rules if rules.deny_all or not rules.allow: conditions.append(sql.SQL("false")) return conditions, params if not rules.allow_all: conditions.append(sql.SQL("topic LIKE ANY(%s)")) params.append(rules.allow_likes()) deny_likes = rules.deny_likes() if deny_likes: conditions.append(sql.SQL("NOT (topic LIKE ANY(%s))")) params.append(deny_likes) return conditions, params # -- Session lookup --------------------------------------------------------- # Row factory is pinned to tuples so this works regardless of the calling # service's pool row_factory. _SESSION_USER_SQL = """ SELECT u.id, u.username, u.role, u.must_change_password, s.csrf_token FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = %s AND s.expires_at > now() AND u.disabled_at IS NULL """ _USER_RULES_SQL = """ SELECT kind, pattern FROM access_rules WHERE user_id = %s UNION ALL SELECT ar.kind, ar.pattern FROM access_rules ar JOIN user_groups ug ON ug.group_id = ar.group_id WHERE ug.user_id = %s """ def hash_token(token: str) -> str: """Sessions store only a SHA-256 of the random token.""" return hashlib.sha256(token.encode("utf-8")).hexdigest() def load_identity(conn: psycopg.Connection[Any], token_hash: str) -> Optional[Identity]: """Resolve a session token hash to an Identity (None if invalid).""" with conn.cursor(row_factory=tuple_row) as cur: user_row = cur.execute(_SESSION_USER_SQL, (token_hash,)).fetchone() if user_row is None: return None user_id, username, role, must_change, csrf_token = user_row rule_rows = cur.execute(_USER_RULES_SQL, (user_id, user_id)).fetchall() allow: list[str] = [] deny: list[str] = [] for kind, pattern in rule_rows: (allow if kind == "allow" else deny).append(pattern) return Identity( user_id=user_id, username=username, role=role, rules=Rules(allow=tuple(allow), deny=tuple(deny)), must_change_password=must_change, csrf_token=csrf_token, )