This commit is contained in:
Jonas Bengtsson
2026-08-30 11:30:09 +02:00
parent 47714fa7e3
commit c139b30f6f
28 changed files with 2100 additions and 34 deletions
+1
View File
@@ -0,0 +1 @@
"""Code shared by the api and webui services."""
+218
View File
@@ -0,0 +1,218 @@
"""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,
)