218 lines
7.3 KiB
Python
218 lines
7.3 KiB
Python
"""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,
|
|
) |