🚧 WIP
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Runtime dependencies are installed by NAME, not by building this project
|
||||
# as a wheel: setuptools auto-discovery would fail on the flat layout
|
||||
# (app/ + migrations/ are two top-level dirs), and the app is run from
|
||||
# WORKDIR anyway, so there is nothing to install from the source tree.
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir \
|
||||
"fastapi>=0.115" \
|
||||
"uvicorn[standard]>=0.32" \
|
||||
"sqlalchemy>=2.0" \
|
||||
"psycopg[binary]>=3.2" \
|
||||
"alembic>=1.14" \
|
||||
"pydantic>=2.9" \
|
||||
"pydantic-settings>=2.6" \
|
||||
"argon2-cffi>=23.1" \
|
||||
"paho-mqtt>=2.1" \
|
||||
"python-multipart>=0.0.17" \
|
||||
"httpx>=0.28"
|
||||
|
||||
# Source is plain files; uvicorn imports app.main from WORKDIR (/app).
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# uvicorn behind the frontend proxy; bind container-internal only.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,4 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
# URL is composed at runtime from environment (see env.py); not stored here.
|
||||
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Audit logging helper (spec section 38).
|
||||
|
||||
Every privileged action writes an append-only AuditEvent row. Secrets and
|
||||
password fields must never be placed in old_value/new_value — strip them
|
||||
at the call site before serializing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
|
||||
def record(
|
||||
db: Session,
|
||||
action: str,
|
||||
*,
|
||||
request: Request | None = None,
|
||||
user: str | None = None,
|
||||
object_type: str | None = None,
|
||||
object_id: str | None = None,
|
||||
old_value: Any = None,
|
||||
new_value: Any = None,
|
||||
result: str = "success",
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
source_ip = None
|
||||
if request is not None:
|
||||
source_ip = request.client.host if request.client else None
|
||||
correlation_id = correlation_id or getattr(request.state, "correlation_id", None)
|
||||
|
||||
db.add(
|
||||
AuditEvent(
|
||||
id=uuid.uuid4(),
|
||||
action=action,
|
||||
user=user,
|
||||
source_ip=source_ip,
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
result=result,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
)
|
||||
# Intentionally no commit here: the request-scoped session commits
|
||||
# atomically with the business change (get_db commits on success).
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Application configuration from environment variables (spec section 7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
app_env: str = "development"
|
||||
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="postgresql+psycopg://mqttadmin:changeme@localhost:5432/mqttadmin",
|
||||
)
|
||||
|
||||
# Management admin bootstrap (spec 7.1) — only consumed when the
|
||||
# users table has no administrator.
|
||||
admin_initial_username: str = "admin"
|
||||
admin_initial_password: str = ""
|
||||
|
||||
# Session signing
|
||||
session_secret: str = ""
|
||||
session_ttl_seconds: int = 60 * 60 * 8
|
||||
|
||||
# Mosquitto management channel
|
||||
mosquitto_host: str = "mosquitto"
|
||||
mosquitto_mgmt_port: int = 1883
|
||||
mosquitto_dynsec_username: str = "admin"
|
||||
mosquitto_dynsec_password: str = ""
|
||||
|
||||
# Control sidecar
|
||||
control_base_url: str = "http://mosquitto-control:8090"
|
||||
control_api_token: str = ""
|
||||
|
||||
# TLS defaults (used by Release 2 config generation)
|
||||
default_tls_min_version: str = "tlsv1.2"
|
||||
default_cert_validity_days: int = 365
|
||||
default_ca_validity_days: int = 3650
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""mosquitto.conf generator — PostgreSQL model to config text (spec sections 61, 25).
|
||||
|
||||
The generated configuration MUST avoid the options deprecated in
|
||||
Mosquitto 2.1 (password_file, acl_file, per_listener_settings): those are
|
||||
removed in 3.0 (spec section 3.1 note).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BrokerSettings, ListenerConfig
|
||||
|
||||
_BOOL = {True: "true", False: "false"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationResult:
|
||||
config_text: str
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _global_section(db: Session, warnings: list[str]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for setting in db.scalars(select(BrokerSettings)).all():
|
||||
key = setting.key.strip()
|
||||
value = setting.value.strip()
|
||||
if not key or key.startswith("#"):
|
||||
continue
|
||||
lines.append(f"{key} {value}")
|
||||
return lines
|
||||
|
||||
|
||||
def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
if not listener.enabled:
|
||||
return lines
|
||||
lines.append(f"listener {listener.port} {listener.bind_address or ''}".rstrip())
|
||||
if listener.bind_address is None:
|
||||
# `listener <port>` without bind address binds all interfaces.
|
||||
lines[-1] = f"listener {listener.port}"
|
||||
lines.append(f"protocol {listener.protocol}")
|
||||
if listener.allow_anonymous:
|
||||
lines.append("listener_allow_anonymous true")
|
||||
if listener.max_connections != -1:
|
||||
lines.append(f"max_connections {listener.max_connections}")
|
||||
if listener.tls_enabled:
|
||||
# TLS blocks only become valid once PKI ships (Release 2); we
|
||||
# generate them now so the model is forward-compatible, but warn.
|
||||
warnings.append(
|
||||
f"listener '{listener.name}': TLS requested but PKI is not "
|
||||
"available in Release 1; certfile/keyfile not set"
|
||||
)
|
||||
if listener.tls_min_version:
|
||||
lines.append(f"tls_version {listener.tls_min_version}")
|
||||
if listener.require_certificate:
|
||||
lines.append("require_certificate true")
|
||||
if listener.use_identity_as_username:
|
||||
lines.append("use_identity_as_username true")
|
||||
return lines
|
||||
|
||||
|
||||
def generate_config(db: Session) -> GenerationResult:
|
||||
"""Build the complete mosquitto.conf from the database model.
|
||||
|
||||
The platform ALWAYS regenerates the full file; it never edits the
|
||||
active file in place (spec section 61).
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
listeners = db.scalars(select(ListenerConfig)).all()
|
||||
enabled = [item for item in listeners if item.enabled]
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Generated by MQTT Management Platform — DO NOT EDIT BY HAND")
|
||||
lines.append("# Ownership: PostgreSQL model -> revision -> this file (spec 61)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("# --- Global settings ---")
|
||||
lines.append("per_listener_settings false")
|
||||
lines.append("allow_anonymous false")
|
||||
global_lines = _global_section(db, warnings)
|
||||
if global_lines:
|
||||
lines.extend(global_lines)
|
||||
lines.append("")
|
||||
|
||||
# Dynamic security plugin: global, so it applies to every listener.
|
||||
lines.append("# --- Dynamic Security ---")
|
||||
lines.append("global_plugin /usr/lib/mosquitto_dynamic_security.so")
|
||||
lines.append("plugin_opt_config_file /mosquitto/config/dynamic-security.json")
|
||||
lines.append("")
|
||||
|
||||
if enabled:
|
||||
lines.append("# --- Listeners ---")
|
||||
for listener in enabled:
|
||||
block = _listener_block(listener, warnings)
|
||||
if block:
|
||||
lines.append(f"# listener: {listener.name}")
|
||||
lines.extend(block)
|
||||
lines.append("")
|
||||
else:
|
||||
warnings.append(
|
||||
"no enabled listeners; broker would fall back to loopback-only "
|
||||
"with anonymous access — refusing to generate"
|
||||
)
|
||||
raise ValueError("no enabled listeners in configuration model")
|
||||
|
||||
lines.append("# --- Persistence ---")
|
||||
lines.append("persistence true")
|
||||
lines.append("persistence_location /mosquitto/data/")
|
||||
lines.append("")
|
||||
|
||||
lines.append("# --- Logging ---")
|
||||
lines.append("log_dest stdout")
|
||||
lines.append("log_timestamp true")
|
||||
lines.append("log_timestamp_format %Y-%m-%dT%H:%M:%S")
|
||||
|
||||
return GenerationResult(config_text="\n".join(lines) + "\n", warnings=warnings)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""SQLAlchemy engine/session plumbing and ORM base class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_engine = None
|
||||
_SessionFactory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _url_with_driver() -> str:
|
||||
url = get_settings().database_url
|
||||
# Accept plain postgresql:// URLs by normalising to the psycopg driver.
|
||||
if url.startswith("postgresql://"):
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(_url_with_driver(), pool_pre_ping=True, future=True)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
global _SessionFactory
|
||||
if _SessionFactory is None:
|
||||
_SessionFactory = sessionmaker(bind=get_engine(), future=True)
|
||||
return _SessionFactory
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""FastAPI dependency yielding a database session per request."""
|
||||
factory = get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""API dependencies: current user, RBAC guards, correlation IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import ManagementRole, ManagementSession, ManagementUser
|
||||
|
||||
SESSION_COOKIE = "mqtt_mgmt_session"
|
||||
|
||||
|
||||
def _expires_at_aware(mgmt_session: ManagementSession) -> datetime:
|
||||
"""Normalize expiry to aware UTC (sqlite returns naive datetimes)."""
|
||||
from datetime import UTC
|
||||
|
||||
expires_at = mgmt_session.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
return expires_at.replace(tzinfo=UTC)
|
||||
return expires_at
|
||||
|
||||
|
||||
def _get_request_session(
|
||||
request: Request, db: Session
|
||||
) -> tuple[ManagementUser, ManagementSession]:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||
try:
|
||||
session_id = uuid.UUID(token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid session") from exc
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
mgmt_session = db.get(ManagementSession, session_id)
|
||||
if (
|
||||
mgmt_session is None
|
||||
or mgmt_session.revoked
|
||||
or _expires_at_aware(mgmt_session) < datetime.now(UTC)
|
||||
):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session expired")
|
||||
user = db.get(ManagementUser, mgmt_session.user_id)
|
||||
if user is None or not user.enabled:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user disabled")
|
||||
return user, mgmt_session
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, db: Annotated[Session, Depends(get_db)]
|
||||
) -> ManagementUser:
|
||||
user, _ = _get_request_session(request, db)
|
||||
return user
|
||||
|
||||
|
||||
def require_roles(*roles: ManagementRole):
|
||||
"""Dependency factory enforcing one of the given management roles."""
|
||||
|
||||
def _guard(
|
||||
user: Annotated[ManagementUser, Depends(get_current_user)],
|
||||
) -> ManagementUser:
|
||||
if user.role == ManagementRole.SUPER_ADMIN:
|
||||
return user
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "insufficient role")
|
||||
return user
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
CurrentUser = Annotated[ManagementUser, Depends(get_current_user)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Dynamic Security client — the runtime control channel (spec sections 10-14).
|
||||
|
||||
The backend connects to Mosquitto as the seeded `admin` client and issues
|
||||
commands on the $CONTROL/dynamic-security/v1 topic API. Commands are
|
||||
request/response: publish a JSON command with a unique correlation topic,
|
||||
then wait for the response on that topic (timeout-bounded).
|
||||
|
||||
Only the subset needed for Release 1 is implemented; the protocol shapes
|
||||
come from the official dynsec documentation (mosquitto.org).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
|
||||
_RESPONSE_TIMEOUT_S = 10.0
|
||||
|
||||
|
||||
class DynsecError(Exception):
|
||||
"""Raised when the dynamic security API returns an error response."""
|
||||
|
||||
|
||||
class DynsecClient:
|
||||
"""Thin, thread-safe wrapper around the dynsec $CONTROL API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: mqtt.Client | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._responses: dict[str, queue.Queue[dict[str, Any]]] = {}
|
||||
self._connected = threading.Event()
|
||||
|
||||
# -- connection lifecycle ------------------------------------------------
|
||||
def connect(self) -> None:
|
||||
with self._lock:
|
||||
if self._client is not None:
|
||||
return
|
||||
settings = get_settings()
|
||||
client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"mqtt-backend-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
client.username_pw_set(
|
||||
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
|
||||
)
|
||||
client.on_connect = self._on_connect
|
||||
client.on_message = self._on_message
|
||||
client.connect_async(
|
||||
settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30
|
||||
)
|
||||
client.loop_start()
|
||||
if not self._connected.wait(timeout=_RESPONSE_TIMEOUT_S):
|
||||
client.loop_stop()
|
||||
raise DynsecError("could not connect to broker management channel")
|
||||
self._client = client
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._client is None:
|
||||
return
|
||||
self._client.disconnect()
|
||||
self._client.loop_stop()
|
||||
self._client = None
|
||||
self._connected.clear()
|
||||
|
||||
# -- paho callbacks -------------------------------------------------------
|
||||
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||
if reason_code == 0:
|
||||
# Subscribe once to the shared response pattern.
|
||||
client.subscribe(f"{CONTROL_TOPIC}/response/#")
|
||||
self._connected.set()
|
||||
else:
|
||||
logger.error("dynsec client connect failed: %s", reason_code)
|
||||
|
||||
def _on_message(self, client, userdata, msg) -> None:
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
logger.warning("unreadable dynsec response on %s", msg.topic)
|
||||
return
|
||||
# Response topic: $CONTROL/dynamic-security/v1/response/<correlation>
|
||||
parts = msg.topic.split("/")
|
||||
correlation = parts[-1] if len(parts) >= 5 else None
|
||||
if correlation and correlation in self._responses:
|
||||
self._responses[correlation].put_nowait(payload)
|
||||
|
||||
# -- command plumbing -----------------------------------------------------
|
||||
def _command(self, command: str, **kwargs: Any) -> dict[str, Any]:
|
||||
self.connect()
|
||||
assert self._client is not None # for type checkers
|
||||
correlation = uuid.uuid4().hex
|
||||
response_topic = f"{CONTROL_TOPIC}/response/{correlation}"
|
||||
reply_queue: queue.Queue[dict[str, Any]] = queue.Queue()
|
||||
self._responses[correlation] = reply_queue
|
||||
body = {"commands": [{"command": command, **kwargs}], "correlation": correlation}
|
||||
try:
|
||||
result = self._client.publish(
|
||||
f"{CONTROL_TOPIC}", json.dumps(body).encode("utf-8"), qos=1
|
||||
)
|
||||
result.wait_for_publish(timeout=_RESPONSE_TIMEOUT_S)
|
||||
try:
|
||||
response = reply_queue.get(timeout=_RESPONSE_TIMEOUT_S)
|
||||
except queue.Empty as exc:
|
||||
raise DynsecError(f"dynsec command '{command}' timed out") from exc
|
||||
finally:
|
||||
self._responses.pop(correlation, None)
|
||||
|
||||
# Protocol shape: {"commands":[{"success":true|false,"error":...}]}
|
||||
commands = response.get("commands", [])
|
||||
entry = commands[0] if commands else {}
|
||||
if not entry.get("success", False):
|
||||
raise DynsecError(entry.get("error", f"dynsec command '{command}' failed"))
|
||||
return entry
|
||||
|
||||
# -- Release 1 API surface (spec sections 11-14) ---------------------------
|
||||
|
||||
# Clients
|
||||
def create_client(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
clientid: str | None = None,
|
||||
text_name: str | None = None,
|
||||
text_description: str | None = None,
|
||||
groups: list[dict[str, Any]] | None = None,
|
||||
roles: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
params: dict[str, Any] = {"username": username, "password": password}
|
||||
if clientid:
|
||||
params["clientid"] = clientid
|
||||
if text_name:
|
||||
params["textname"] = text_name
|
||||
if text_description:
|
||||
params["textdescription"] = text_description
|
||||
if groups:
|
||||
params["groups"] = groups
|
||||
if roles:
|
||||
params["roles"] = roles
|
||||
self._command("createClient", **params)
|
||||
|
||||
def delete_client(self, username: str) -> None:
|
||||
self._command("deleteClient", username=username)
|
||||
|
||||
def enable_client(self, username: str) -> None:
|
||||
self._command("enableClient", username=username)
|
||||
|
||||
def disable_client(self, username: str) -> None:
|
||||
self._command("disableClient", username=username)
|
||||
|
||||
def set_client_password(self, username: str, password: str) -> None:
|
||||
self._command("setClientPassword", username=username, password=password)
|
||||
|
||||
def get_client(self, username: str) -> dict[str, Any]:
|
||||
return self._command("getClient", username=username).get("client", {})
|
||||
|
||||
def list_clients(self) -> list[dict[str, Any]]:
|
||||
entry = self._command("listClients")
|
||||
return entry.get("clients", [])
|
||||
|
||||
def set_client_group_memberships(
|
||||
self, username: str, groups: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Replace a client's complete group membership list."""
|
||||
self._command("modifyClient", username=username, groups=groups)
|
||||
|
||||
def set_client_roles(self, username: str, roles: list[dict[str, Any]]) -> None:
|
||||
"""Replace a client's direct role assignments."""
|
||||
self._command("modifyClient", username=username, roles=roles)
|
||||
|
||||
# Groups
|
||||
def create_group(self, groupname: str, roles: list[dict[str, Any]] | None = None) -> None:
|
||||
params: dict[str, Any] = {"groupname": groupname}
|
||||
if roles:
|
||||
params["roles"] = roles
|
||||
self._command("createGroup", **params)
|
||||
|
||||
def delete_group(self, groupname: str) -> None:
|
||||
self._command("deleteGroup", groupname=groupname)
|
||||
|
||||
def get_group(self, groupname: str) -> dict[str, Any]:
|
||||
return self._command("getGroup", groupname=groupname).get("group", {})
|
||||
|
||||
def list_groups(self) -> list[dict[str, Any]]:
|
||||
entry = self._command("listGroups")
|
||||
return entry.get("groups", [])
|
||||
|
||||
def set_group_roles(self, groupname: str, roles: list[dict[str, Any]]) -> None:
|
||||
self._command("modifyGroup", groupname=groupname, roles=roles)
|
||||
|
||||
def add_group_client(self, groupname: str, username: str, priority: int = 1) -> None:
|
||||
self._command(
|
||||
"addGroupClient", groupname=groupname, username=username, priority=priority
|
||||
)
|
||||
|
||||
def remove_group_client(self, groupname: str, username: str) -> None:
|
||||
self._command("removeGroupClient", groupname=groupname, username=username)
|
||||
|
||||
# Roles
|
||||
def create_role(self, rolename: str, acls: list[dict[str, Any]] | None = None) -> None:
|
||||
params: dict[str, Any] = {"rolename": rolename}
|
||||
if acls:
|
||||
params["acls"] = acls
|
||||
self._command("createRole", **params)
|
||||
|
||||
def delete_role(self, rolename: str) -> None:
|
||||
self._command("deleteRole", rolename=rolename)
|
||||
|
||||
def get_role(self, rolename: str) -> dict[str, Any]:
|
||||
return self._command("getRole", rolename=rolename).get("role", {})
|
||||
|
||||
def list_roles(self) -> list[dict[str, Any]]:
|
||||
entry = self._command("listRoles")
|
||||
return entry.get("roles", [])
|
||||
|
||||
def set_role_acls(self, rolename: str, acls: list[dict[str, Any]]) -> None:
|
||||
"""Replace the full ACL list of a role (spec section 14 editor)."""
|
||||
self._command("modifyRole", rolename=rolename, acls=acls)
|
||||
|
||||
# Default ACL behaviour (spec section 58: deny by default)
|
||||
def set_default_acl_access(
|
||||
self, acl_type: str, allow: bool
|
||||
) -> None:
|
||||
self._command(
|
||||
"setDefaultACLAccess",
|
||||
defaultAclAccess=[{"acltype": acl_type, "allow": allow}],
|
||||
)
|
||||
|
||||
def get_default_acl_access(self) -> list[dict[str, Any]]:
|
||||
entry = self._command("getDefaultACLAccess")
|
||||
return entry.get("defaultAclAccess", [])
|
||||
|
||||
|
||||
# Module-level singleton used by FastAPI dependencies.
|
||||
dynsec_client = DynsecClient()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""FastAPI application factory, bootstrap, and health endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import Base, get_engine
|
||||
from app.dynsec import dynsec_client
|
||||
from app.models import ManagementRole, ManagementUser
|
||||
from app.security import hash_password
|
||||
|
||||
logger = logging.getLogger("mqtt-backend")
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def _bootstrap_admin() -> None:
|
||||
"""Create the initial management admin if none exists (spec section 7.1)."""
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
settings = get_settings()
|
||||
url = settings.database_url
|
||||
if url.startswith("postgresql://"):
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
engine = create_engine(url, future=True)
|
||||
|
||||
with Session(engine) as session:
|
||||
has_admin = session.scalar(
|
||||
select(ManagementUser.id).where(
|
||||
ManagementUser.role == ManagementRole.SUPER_ADMIN
|
||||
)
|
||||
)
|
||||
if has_admin is None:
|
||||
if not settings.admin_initial_password:
|
||||
raise RuntimeError(
|
||||
"no administrator exists and ADMIN_INITIAL_PASSWORD is not set"
|
||||
)
|
||||
session.add(
|
||||
ManagementUser(
|
||||
username=settings.admin_initial_username,
|
||||
password_hash=hash_password(settings.admin_initial_password),
|
||||
role=ManagementRole.SUPER_ADMIN,
|
||||
must_change_password=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
logger.info("bootstrapped initial admin user %r", settings.admin_initial_username)
|
||||
else:
|
||||
logger.info("administrator already present; bootstrap skipped")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
# Create tables on startup for the development stack. Production uses
|
||||
# explicit Alembic migrations (spec section 63) — the tables are
|
||||
# identical because the models are the single source of truth.
|
||||
Base.metadata.create_all(get_engine())
|
||||
_bootstrap_admin()
|
||||
try:
|
||||
dynsec_client.connect()
|
||||
logger.info("dynamic security channel connected")
|
||||
except Exception as exc: # noqa: BLE001 - startup should not crash the API
|
||||
logger.warning("dynsec channel not ready yet: %s", exc)
|
||||
yield
|
||||
dynsec_client.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="MQTT Management Platform API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# The frontend is served by nginx which proxies /api to this backend; CORS
|
||||
# only matters for local development where Vite runs on another port.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"] if not get_settings().is_production else [],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def correlation_id_middleware(request: Request, call_next):
|
||||
"""Attach a correlation ID to every request (spec section 42)."""
|
||||
correlation_id = request.headers.get("X-Correlation-ID") or uuid.uuid4().hex
|
||||
request.state.correlation_id = correlation_id
|
||||
response = await call_next(request)
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health/live")
|
||||
def health_live():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/health/ready")
|
||||
def health_ready():
|
||||
"""Readiness: database + dynsec management channel (spec section 46)."""
|
||||
checks: dict[str, str] = {}
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
with get_engine().connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
checks["database"] = f"error: {exc}"
|
||||
|
||||
checks["dynsec_channel"] = "ok" if dynsec_client._connected.is_set() else "not connected"
|
||||
checks["control_service"] = "ok" # probed lazily on deploy
|
||||
|
||||
ready = checks["database"] == "ok"
|
||||
return {"status": "ready" if ready else "degraded", "checks": checks}
|
||||
|
||||
|
||||
# Routers
|
||||
from app.routers import auth, broker, mqtt, users # noqa: E402
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(mqtt.router)
|
||||
app.include_router(broker.router)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""ORM models — Release 1 subset (spec sections 39, 9, 11, 13, 14, 26).
|
||||
|
||||
Dynamic Security objects (clients/groups/roles) are NOT stored here; the
|
||||
broker owns them (spec section 40). PostgreSQL holds management users,
|
||||
RBAC, broker config model + revisions, and MQTT client metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Timezone-aware UTC now — Postgres timestamptz requires aware values."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Management identities + RBAC (spec sections 8, 9)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ManagementRole(str, enum.Enum):
|
||||
"""Built-in platform roles (spec section 9). Custom roles: later release."""
|
||||
|
||||
SUPER_ADMIN = "super_admin"
|
||||
BROKER_ADMIN = "broker_admin"
|
||||
PKI_ADMIN = "pki_admin"
|
||||
OPERATOR = "operator"
|
||||
AUDITOR = "auditor"
|
||||
|
||||
|
||||
class ManagementUser(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
# Argon2id hash (spec section 43); never the plaintext password.
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
display_name: Mapped[str | None] = mapped_column(String(200))
|
||||
role: Mapped[ManagementRole] = mapped_column(
|
||||
Enum(ManagementRole, name="management_role"),
|
||||
default=ManagementRole.BROKER_ADMIN,
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
modified_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
class ManagementSession(Base):
|
||||
"""Server-side session store (spec section 8: revocable, expiring)."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Broker configuration model + revisions (spec sections 25, 26, 30, 31, 61)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ListenerConfig(Base):
|
||||
"""One Mosquitto listener as managed through the UI (spec section 30)."""
|
||||
|
||||
__tablename__ = "broker_listeners"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
port: Mapped[int] = mapped_column(Integer)
|
||||
bind_address: Mapped[str | None] = mapped_column(String(100))
|
||||
protocol: Mapped[str] = mapped_column(String(20), default="mqtt") # mqtt|websockets
|
||||
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
max_connections: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
# TLS settings (used once PKI ships in Release 2; kept now so the
|
||||
# config model is stable from the start).
|
||||
tls_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
tls_min_version: Mapped[str | None] = mapped_column(String(20))
|
||||
require_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
use_identity_as_username: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
cert_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("certificates.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
modified_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
class BrokerSettings(Base):
|
||||
"""Key/value store for global broker options (spec section 31)."""
|
||||
|
||||
__tablename__ = "broker_settings"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
value_type: Mapped[str] = mapped_column(String(20), default="string")
|
||||
|
||||
|
||||
class ConfigRevision(Base):
|
||||
"""Generated mosquitto.conf revisions (spec sections 26, 61)."""
|
||||
|
||||
__tablename__ = "broker_config_revisions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
revision_number: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(100))
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
# Full generated mosquitto.conf text
|
||||
config_text: Mapped[str] = mapped_column(Text)
|
||||
validation_result: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
deployed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("revision_number", name="uq_config_revision_number"),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PKI placeholders (populated in Release 2; referenced by listeners)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Certificate(Base):
|
||||
__tablename__ = "certificates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
common_name: Mapped[str] = mapped_column(String(200))
|
||||
certificate_pem: Mapped[str] = mapped_column(Text)
|
||||
# Encrypted at rest (AES-256-GCM) once keys are managed (Release 2).
|
||||
private_key_pem: Mapped[str | None] = mapped_column(Text)
|
||||
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MQTT client metadata (spec sections 11, 40) — dynsec owns the auth data,
|
||||
# PostgreSQL stores the descriptive/ownership metadata.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MqttClientMetadata(Base):
|
||||
__tablename__ = "mqtt_client_metadata"
|
||||
|
||||
username: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(200))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
owner: Mapped[str | None] = mapped_column(String(200))
|
||||
purpose: Mapped[str | None] = mapped_column(String(200))
|
||||
tags: Mapped[list] = mapped_column(JSON, default=list)
|
||||
last_known_connection: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
modified_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
# Certificate link becomes active in Release 2 (mTLS identity binding).
|
||||
certificate_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("certificates.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Audit log (spec section 38) — append-only from the application's view.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, index=True
|
||||
)
|
||||
user: Mapped[str | None] = mapped_column(String(100))
|
||||
source_ip: Mapped[str | None] = mapped_column(String(64))
|
||||
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||
object_type: Mapped[str | None] = mapped_column(String(100))
|
||||
object_id: Mapped[str | None] = mapped_column(String(200))
|
||||
old_value: Mapped[str | None] = mapped_column(JSON)
|
||||
new_value: Mapped[str | None] = mapped_column(JSON)
|
||||
result: Mapped[str] = mapped_column(String(20), default="success")
|
||||
correlation_id: Mapped[str | None] = mapped_column(String(64))
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers package."""
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Management authentication: login/logout, session cookies (spec section 8)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import audit
|
||||
from app.config import get_settings
|
||||
from app.db import get_db
|
||||
from app.deps import SESSION_COOKIE, CurrentUser
|
||||
from app.models import ManagementSession, ManagementUser
|
||||
from app.schemas import LoginRequest, PasswordChangeRequest, SessionInfo
|
||||
from app.security import (
|
||||
generate_session_id,
|
||||
hash_password,
|
||||
session_expiry,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/login", response_model=SessionInfo)
|
||||
def login(body: LoginRequest, request: Request, response: Response, db=Depends(get_db)):
|
||||
user = db.scalar(
|
||||
select(ManagementUser).where(ManagementUser.username == body.username)
|
||||
)
|
||||
ok = user is not None and user.enabled and verify_password(
|
||||
user.password_hash, body.password
|
||||
)
|
||||
if not ok:
|
||||
audit.record(
|
||||
db,
|
||||
"LOGIN_FAILED",
|
||||
request=request,
|
||||
user=body.username,
|
||||
result="failure",
|
||||
)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||
|
||||
session = ManagementSession(
|
||||
id=generate_session_id(),
|
||||
user_id=user.id,
|
||||
expires_at=session_expiry(settings.session_ttl_seconds),
|
||||
)
|
||||
db.add(session)
|
||||
audit.record(db, "LOGIN", request=request, user=user.username)
|
||||
db.flush()
|
||||
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
str(session.id),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=settings.is_production,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
return SessionInfo(
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response, user: CurrentUser, db=Depends(get_db)):
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token:
|
||||
mgmt_session = db.get(ManagementSession, token)
|
||||
if mgmt_session is not None:
|
||||
mgmt_session.revoked = True
|
||||
audit.record(db, "LOGOUT", request=request, user=user.username)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return {"status": "logged_out"}
|
||||
|
||||
|
||||
@router.get("/session", response_model=SessionInfo)
|
||||
def whoami(user: CurrentUser) -> SessionInfo:
|
||||
return SessionInfo(
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
def change_password(
|
||||
body: PasswordChangeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: CurrentUser,
|
||||
db=Depends(get_db),
|
||||
):
|
||||
if not verify_password(user.password_hash, body.current_password):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "current password incorrect")
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.must_change_password = False
|
||||
# Revoke all other sessions as a precaution after credential change.
|
||||
for mgmt_session in db.scalars(
|
||||
select(ManagementSession).where(ManagementSession.user_id == user.id)
|
||||
):
|
||||
mgmt_session.revoked = True
|
||||
audit.record(db, "PASSWORD_CHANGED", request=request, user=user.username)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return {"status": "password_changed"}
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Broker configuration management (spec sections 25-27, 30, 31, 61).
|
||||
|
||||
Release 1 flow: edit model -> generate -> validate via control sidecar ->
|
||||
deploy (activate + reload) -> revision recorded. Diffs and approval
|
||||
workflow arrive with Release 2/3 polish, but every deploy already creates
|
||||
an immutable revision (spec section 26).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.config import get_settings
|
||||
from app.configgen import generate_config
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import (
|
||||
BrokerSettings,
|
||||
ConfigRevision,
|
||||
ListenerConfig,
|
||||
ManagementRole,
|
||||
)
|
||||
from app.schemas import (
|
||||
BrokerSettingsIn,
|
||||
ConfigPreviewOut,
|
||||
DeployRequest,
|
||||
ListenerIn,
|
||||
ListenerOut,
|
||||
RevisionDetailOut,
|
||||
RevisionOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/broker", tags=["broker"])
|
||||
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
AnyReader = Annotated[
|
||||
CurrentUser,
|
||||
Depends(
|
||||
require_roles(
|
||||
ManagementRole.BROKER_ADMIN,
|
||||
ManagementRole.OPERATOR,
|
||||
ManagementRole.AUDITOR,
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _next_revision_number(db: Session) -> int:
|
||||
highest = db.scalar(
|
||||
select(ConfigRevision.revision_number).order_by(
|
||||
ConfigRevision.revision_number.desc()
|
||||
)
|
||||
)
|
||||
return (highest or 0) + 1
|
||||
|
||||
|
||||
def _call_control(
|
||||
path: str, *, method: str = "POST", json_body: dict | None = None
|
||||
) -> dict:
|
||||
settings = get_settings()
|
||||
headers = {"Authorization": f"Bearer {settings.control_api_token}"}
|
||||
try:
|
||||
with httpx.Client(base_url=settings.control_base_url, timeout=30) as client:
|
||||
response = client.request(
|
||||
method, path, headers=headers, json=json_body
|
||||
)
|
||||
except httpx.TransportError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"broker control service unreachable: {exc}",
|
||||
) from exc
|
||||
if response.status_code >= 400:
|
||||
detail = response.json().get("error", response.text)
|
||||
raise HTTPException(response.status_code, detail)
|
||||
return response.json()
|
||||
|
||||
|
||||
# --- Listeners ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/listeners", response_model=list[ListenerOut])
|
||||
def list_listeners(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||
return db.scalars(select(ListenerConfig)).all()
|
||||
|
||||
|
||||
@router.post("/listeners", response_model=ListenerOut, status_code=201)
|
||||
def create_listener(
|
||||
body: ListenerIn,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
listener = ListenerConfig(**body.model_dump())
|
||||
db.add(listener)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"LISTENER_CREATED",
|
||||
request=request,
|
||||
object_type="listener",
|
||||
object_id=str(listener.id),
|
||||
new_value={"name": body.name, "port": body.port},
|
||||
)
|
||||
return listener
|
||||
|
||||
|
||||
@router.put("/listeners/{listener_id}", response_model=ListenerOut)
|
||||
def update_listener(
|
||||
listener_id: uuid.UUID,
|
||||
body: ListenerIn,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
listener = db.get(ListenerConfig, listener_id)
|
||||
if listener is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
|
||||
for key, value in body.model_dump().items():
|
||||
setattr(listener, key, value)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"LISTENER_UPDATED",
|
||||
request=request,
|
||||
object_type="listener",
|
||||
object_id=str(listener.id),
|
||||
new_value={"name": body.name, "port": body.port},
|
||||
)
|
||||
return listener
|
||||
|
||||
|
||||
@router.delete("/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_listener(
|
||||
listener_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
listener = db.get(ListenerConfig, listener_id)
|
||||
if listener is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
|
||||
db.delete(listener)
|
||||
audit.record(
|
||||
db,
|
||||
"LISTENER_DELETED",
|
||||
request=request,
|
||||
object_type="listener",
|
||||
object_id=str(listener.id),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# --- Global settings -----------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/settings", response_model=list[BrokerSettingsIn])
|
||||
def list_settings(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||
return db.scalars(select(BrokerSettings)).all()
|
||||
|
||||
|
||||
@router.put("/settings/{key}", response_model=BrokerSettingsIn)
|
||||
def upsert_setting(
|
||||
key: str,
|
||||
body: BrokerSettingsIn,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
if body.key != key:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "key mismatch")
|
||||
setting = db.scalar(select(BrokerSettings).where(BrokerSettings.key == key))
|
||||
if setting is None:
|
||||
setting = BrokerSettings(key=key, value=body.value, value_type=body.value_type)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = body.value
|
||||
setting.value_type = body.value_type
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"BROKER_SETTING_CHANGED",
|
||||
request=request,
|
||||
object_type="broker_setting",
|
||||
object_id=key,
|
||||
new_value={"value": body.value},
|
||||
)
|
||||
return setting
|
||||
|
||||
|
||||
# --- Generation, validation, deployment ----------------------------------------
|
||||
|
||||
|
||||
@router.get("/config/preview", response_model=ConfigPreviewOut)
|
||||
def preview_config(
|
||||
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||
):
|
||||
"""Show the generated mosquitto.conf without deploying (spec section 32)."""
|
||||
result = generate_config(db)
|
||||
return ConfigPreviewOut(config_text=result.config_text, warnings=result.warnings)
|
||||
|
||||
|
||||
@router.post("/config/validate")
|
||||
def validate_config(
|
||||
db: Annotated[Session, Depends(get_db)], _user: BrokerAdmin
|
||||
):
|
||||
"""Generate + validate against the real broker binary (spec section 27)."""
|
||||
result = generate_config(db)
|
||||
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||
# NOTE: /deploy both validates AND activates. Release 1 keeps the
|
||||
# combined flow; Release 2 splits validate-only via a staged endpoint.
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/config/deploy")
|
||||
def deploy_config(
|
||||
body: DeployRequest,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
user: BrokerAdmin,
|
||||
):
|
||||
"""Generate -> validate -> activate + reload -> record revision."""
|
||||
result = generate_config(db)
|
||||
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||
if response.get("status") != "deployed":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
|
||||
|
||||
revision = ConfigRevision(
|
||||
revision_number=_next_revision_number(db),
|
||||
created_by=user.username,
|
||||
comment=body.comment,
|
||||
config_text=result.config_text,
|
||||
validation_result=response,
|
||||
deployed=True,
|
||||
deployed_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(revision)
|
||||
audit.record(
|
||||
db,
|
||||
"BROKER_CONFIG_DEPLOYED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="config_revision",
|
||||
object_id=str(revision.revision_number),
|
||||
new_value={"comment": body.comment},
|
||||
)
|
||||
return {
|
||||
"status": "deployed",
|
||||
"revision": revision.revision_number,
|
||||
"broker_healthy": response.get("broker_healthy"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/revisions", response_model=list[RevisionOut])
|
||||
def list_revisions(
|
||||
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||
):
|
||||
return db.scalars(
|
||||
select(ConfigRevision).order_by(ConfigRevision.revision_number.desc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.get("/revisions/{number}", response_model=RevisionDetailOut)
|
||||
def get_revision(
|
||||
number: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: AnyReader,
|
||||
):
|
||||
revision = db.scalar(
|
||||
select(ConfigRevision).where(ConfigRevision.revision_number == number)
|
||||
)
|
||||
if revision is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "revision not found")
|
||||
return revision
|
||||
@@ -0,0 +1,307 @@
|
||||
"""MQTT identity management via Dynamic Security (spec sections 11-14).
|
||||
|
||||
Source-of-truth rules (spec section 40): clients/groups/roles live in the
|
||||
broker; this API proxies to dynsec and keeps supplementary metadata
|
||||
(owner/purpose/tags) in PostgreSQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.dynsec import DynsecError, dynsec_client
|
||||
from app.models import ManagementRole, MqttClientMetadata
|
||||
from app.schemas import (
|
||||
GroupIn,
|
||||
MqttClientCreate,
|
||||
MqttClientOut,
|
||||
MqttPasswordReset,
|
||||
RoleIn,
|
||||
RoleUpdate,
|
||||
)
|
||||
from app.security import generate_password
|
||||
|
||||
router = APIRouter(prefix="/api/v1/mqtt", tags=["mqtt-security"])
|
||||
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
|
||||
return db.get(MqttClientMetadata, username)
|
||||
|
||||
|
||||
def _client_out(db: Session, client: dict) -> MqttClientOut:
|
||||
meta = _metadata(db, client.get("username", ""))
|
||||
return MqttClientOut(
|
||||
username=client.get("username", ""),
|
||||
clientid=client.get("clientid") or None,
|
||||
text_name=client.get("textname"),
|
||||
text_description=client.get("textdescription"),
|
||||
enabled=client.get("enabled", False),
|
||||
groups=client.get("groups", []),
|
||||
roles=client.get("roles", []),
|
||||
owner=meta.owner if meta else None,
|
||||
purpose=meta.purpose if meta else None,
|
||||
tags=meta.tags if meta else [],
|
||||
last_known_connection=meta.last_known_connection if meta else None,
|
||||
)
|
||||
|
||||
|
||||
# --- Clients ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/clients", response_model=list[MqttClientOut])
|
||||
def list_clients(db: DbDep, _user: BrokerAdmin):
|
||||
return [_client_out(db, c) for c in dynsec_client.list_clients()]
|
||||
|
||||
|
||||
@router.post("/clients", response_model=MqttPasswordReset, status_code=201)
|
||||
def create_client(
|
||||
body: MqttClientCreate,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
"""Create a dynsec client; the plaintext password is returned ONCE."""
|
||||
password = body.password or generate_password()
|
||||
try:
|
||||
dynsec_client.create_client(
|
||||
username=body.username,
|
||||
password=password,
|
||||
clientid=body.clientid,
|
||||
text_name=body.text_name,
|
||||
text_description=body.text_description,
|
||||
groups=[g.model_dump() for g in body.groups],
|
||||
roles=[r.model_dump() for r in body.roles],
|
||||
)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
|
||||
db.add(
|
||||
MqttClientMetadata(
|
||||
username=body.username,
|
||||
owner=body.owner,
|
||||
purpose=body.purpose,
|
||||
tags=body.tags,
|
||||
)
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
"CLIENT_CREATED",
|
||||
request=request,
|
||||
object_type="mqtt_client",
|
||||
object_id=body.username,
|
||||
new_value={"username": body.username, "groups": len(body.groups)},
|
||||
)
|
||||
return MqttPasswordReset(username=body.username, password=password)
|
||||
|
||||
|
||||
@router.get("/clients/{username}", response_model=MqttClientOut)
|
||||
def get_client(username: str, db: DbDep, _user: BrokerAdmin):
|
||||
try:
|
||||
client = dynsec_client.get_client(username)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
return _client_out(db, client)
|
||||
|
||||
|
||||
@router.delete("/clients/{username}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_client(
|
||||
username: str,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
try:
|
||||
dynsec_client.delete_client(username)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
meta = _metadata(db, username)
|
||||
if meta is not None:
|
||||
db.delete(meta)
|
||||
audit.record(
|
||||
db,
|
||||
"CLIENT_DELETED",
|
||||
request=request,
|
||||
object_type="mqtt_client",
|
||||
object_id=username,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/clients/{username}/disable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def disable_client(
|
||||
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||
):
|
||||
dynsec_client.disable_client(username)
|
||||
audit.record(
|
||||
db, "CLIENT_DISABLED", request=request, object_type="mqtt_client", object_id=username
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/clients/{username}/enable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def enable_client(
|
||||
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||
):
|
||||
dynsec_client.enable_client(username)
|
||||
audit.record(
|
||||
db, "CLIENT_ENABLED", request=request, object_type="mqtt_client", object_id=username
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/clients/{username}/password:reset", response_model=MqttPasswordReset)
|
||||
def reset_password(
|
||||
username: str,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
"""Generate a new random password; returned once (spec section 11)."""
|
||||
password = generate_password()
|
||||
try:
|
||||
dynsec_client.set_client_password(username, password)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"CLIENT_PASSWORD_RESET",
|
||||
request=request,
|
||||
object_type="mqtt_client",
|
||||
object_id=username,
|
||||
)
|
||||
return MqttPasswordReset(username=username, password=password)
|
||||
|
||||
|
||||
# --- Groups ------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def list_groups(_user: BrokerAdmin):
|
||||
return dynsec_client.list_groups()
|
||||
|
||||
|
||||
@router.post("/groups", status_code=status.HTTP_201_CREATED)
|
||||
def create_group(
|
||||
body: GroupIn, request: Request, db: DbDep, _user: BrokerAdmin
|
||||
):
|
||||
try:
|
||||
dynsec_client.create_group(
|
||||
groupname=body.groupname,
|
||||
roles=[r.model_dump() for r in body.roles],
|
||||
)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"GROUP_CREATED",
|
||||
request=request,
|
||||
object_type="mqtt_group",
|
||||
object_id=body.groupname,
|
||||
)
|
||||
return {"groupname": body.groupname}
|
||||
|
||||
|
||||
@router.delete("/groups/{groupname}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(
|
||||
groupname: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||
):
|
||||
try:
|
||||
dynsec_client.delete_group(groupname)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"GROUP_DELETED",
|
||||
request=request,
|
||||
object_type="mqtt_group",
|
||||
object_id=groupname,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles(_user: BrokerAdmin):
|
||||
return dynsec_client.list_roles()
|
||||
|
||||
|
||||
@router.get("/roles/{rolename}")
|
||||
def get_role(rolename: str, _user: BrokerAdmin):
|
||||
"""Full role detail including ACLs — used by the ACL editor."""
|
||||
try:
|
||||
return dynsec_client.get_role(rolename)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/roles", status_code=status.HTTP_201_CREATED)
|
||||
def create_role(
|
||||
body: RoleIn, request: Request, db: DbDep, _user: BrokerAdmin
|
||||
):
|
||||
"""Create a role with its full ACL set (spec sections 13-14)."""
|
||||
try:
|
||||
dynsec_client.create_role(
|
||||
rolename=body.rolename,
|
||||
acls=[a.model_dump() for a in body.acls],
|
||||
)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"ROLE_CREATED",
|
||||
request=request,
|
||||
object_type="mqtt_role",
|
||||
object_id=body.rolename,
|
||||
new_value={"acl_count": len(body.acls)},
|
||||
)
|
||||
return {"rolename": body.rolename}
|
||||
|
||||
|
||||
@router.put("/roles/{rolename}")
|
||||
def update_role(
|
||||
rolename: str,
|
||||
body: RoleUpdate,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
"""Replace the role's ACL list — the ACL editor's save action."""
|
||||
try:
|
||||
dynsec_client.set_role_acls(rolename, [a.model_dump() for a in body.acls])
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"ACL_CHANGED",
|
||||
request=request,
|
||||
object_type="mqtt_role",
|
||||
object_id=rolename,
|
||||
new_value={"acl_count": len(body.acls)},
|
||||
)
|
||||
return {"rolename": rolename}
|
||||
|
||||
|
||||
@router.delete("/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_role(
|
||||
rolename: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||
):
|
||||
try:
|
||||
dynsec_client.delete_role(rolename)
|
||||
except DynsecError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"ROLE_DELETED",
|
||||
request=request,
|
||||
object_type="mqtt_role",
|
||||
object_id=rolename,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Management users admin (spec sections 7.1, 9)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import audit
|
||||
from app.deps import CurrentUser, DbSession, require_roles
|
||||
from app.models import ManagementRole, ManagementUser
|
||||
from app.schemas import (
|
||||
ManagementUserCreate,
|
||||
ManagementUserOut,
|
||||
ManagementUserUpdate,
|
||||
)
|
||||
from app.security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
AdminUser = Annotated[CurrentUser, Depends(require_roles(ManagementRole.SUPER_ADMIN))]
|
||||
|
||||
|
||||
@router.get("", response_model=list[ManagementUserOut])
|
||||
def list_users(db: DbSession, _admin: AdminUser):
|
||||
return db.scalars(select(ManagementUser)).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ManagementUserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
body: ManagementUserCreate,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
_admin: AdminUser,
|
||||
):
|
||||
if db.scalar(select(ManagementUser).where(ManagementUser.username == body.username)):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "username already exists")
|
||||
user = ManagementUser(
|
||||
username=body.username,
|
||||
password_hash=hash_password(body.password),
|
||||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
must_change_password=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"USER_CREATED",
|
||||
request=request,
|
||||
user=body.username,
|
||||
object_type="management_user",
|
||||
object_id=body.username,
|
||||
new_value={"role": body.role.value},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{username}", response_model=ManagementUserOut)
|
||||
def update_user(
|
||||
username: str,
|
||||
body: ManagementUserUpdate,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
_admin: AdminUser,
|
||||
):
|
||||
user = db.scalar(select(ManagementUser).where(ManagementUser.username == username))
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
old = {"role": user.role.value, "enabled": user.enabled}
|
||||
if body.display_name is not None:
|
||||
user.display_name = body.display_name
|
||||
if body.role is not None:
|
||||
user.role = body.role
|
||||
if body.enabled is not None:
|
||||
user.enabled = body.enabled
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"USER_UPDATED",
|
||||
request=request,
|
||||
user=username,
|
||||
object_type="management_user",
|
||||
object_id=username,
|
||||
old_value=old,
|
||||
new_value={"role": user.role.value, "enabled": user.enabled},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{username}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
username: str,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
_admin: AdminUser,
|
||||
):
|
||||
if username == "admin":
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "cannot delete bootstrap admin")
|
||||
user = db.scalar(select(ManagementUser).where(ManagementUser.username == username))
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
db.delete(user)
|
||||
audit.record(
|
||||
db,
|
||||
"USER_DELETED",
|
||||
request=request,
|
||||
user=username,
|
||||
object_type="management_user",
|
||||
object_id=username,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Pydantic request/response schemas shared across routers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
|
||||
from app.models import ManagementRole
|
||||
|
||||
UsernameStr = Annotated[str, StringConstraints(pattern=r"^[a-zA-Z0-9_-]{1,100}$")]
|
||||
TopicFilterStr = Annotated[str, StringConstraints(min_length=1, max_length=200)]
|
||||
|
||||
ACL_TYPES = Literal[
|
||||
"publishClientSend",
|
||||
"publishClientReceive",
|
||||
"subscribeLiteral",
|
||||
"subscribePattern",
|
||||
"unsubscribeLiteral",
|
||||
"unsubscribePattern",
|
||||
]
|
||||
|
||||
|
||||
# --- Auth ------------------------------------------------------------------
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
username: str
|
||||
role: ManagementRole
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=12)
|
||||
|
||||
|
||||
# --- Management users -------------------------------------------------------
|
||||
|
||||
|
||||
class ManagementUserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
display_name: str | None
|
||||
role: ManagementRole
|
||||
enabled: bool
|
||||
must_change_password: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ManagementUserCreate(BaseModel):
|
||||
username: UsernameStr
|
||||
password: str = Field(min_length=12)
|
||||
display_name: str | None = None
|
||||
role: ManagementRole = ManagementRole.OPERATOR
|
||||
|
||||
|
||||
class ManagementUserUpdate(BaseModel):
|
||||
display_name: str | None = None
|
||||
role: ManagementRole | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
# --- MQTT clients ------------------------------------------------------------
|
||||
|
||||
|
||||
class AclRuleIn(BaseModel):
|
||||
"""One ACL rule as edited in the UI (spec section 14)."""
|
||||
|
||||
acltype: ACL_TYPES
|
||||
topic: TopicFilterStr
|
||||
allow: bool = True
|
||||
priority: int = Field(default=0, ge=0, le=100000)
|
||||
|
||||
|
||||
class RoleIn(BaseModel):
|
||||
rolename: UsernameStr
|
||||
text_name: str | None = None
|
||||
text_description: str | None = None
|
||||
acls: list[AclRuleIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
"""PUT /roles/{name} payload: rolename comes from the URL path."""
|
||||
|
||||
acls: list[AclRuleIn]
|
||||
|
||||
|
||||
class GroupRoleIn(BaseModel):
|
||||
rolename: str
|
||||
priority: int = 1
|
||||
|
||||
|
||||
class GroupIn(BaseModel):
|
||||
groupname: UsernameStr
|
||||
text_name: str | None = None
|
||||
text_description: str | None = None
|
||||
roles: list[GroupRoleIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClientRoleIn(BaseModel):
|
||||
rolename: str
|
||||
priority: int = 1
|
||||
|
||||
|
||||
class ClientGroupIn(BaseModel):
|
||||
groupname: str
|
||||
priority: int = 1
|
||||
|
||||
|
||||
class MqttClientCreate(BaseModel):
|
||||
username: UsernameStr
|
||||
password: str | None = Field(
|
||||
default=None,
|
||||
description="If omitted a random password is generated and returned once.",
|
||||
)
|
||||
clientid: str | None = None
|
||||
text_name: str | None = None
|
||||
text_description: str | None = None
|
||||
groups: list[ClientGroupIn] = Field(default_factory=list)
|
||||
roles: list[ClientRoleIn] = Field(default_factory=list)
|
||||
# PostgreSQL-side metadata (spec section 11)
|
||||
owner: str | None = None
|
||||
purpose: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MqttClientOut(BaseModel):
|
||||
username: str
|
||||
clientid: str | None
|
||||
text_name: str | None
|
||||
text_description: str | None
|
||||
enabled: bool
|
||||
groups: list[dict[str, Any]]
|
||||
roles: list[dict[str, Any]]
|
||||
owner: str | None
|
||||
purpose: str | None
|
||||
tags: list[str]
|
||||
last_known_connection: datetime | None
|
||||
|
||||
|
||||
class MqttPasswordReset(BaseModel):
|
||||
"""Response for create/reset — plaintext shown exactly once (spec 11)."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
# --- Broker configuration ----------------------------------------------------
|
||||
|
||||
|
||||
class BrokerSettingsIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=100)
|
||||
value: str
|
||||
value_type: Literal["string", "int", "bool"] = "string"
|
||||
|
||||
|
||||
class ListenerIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
enabled: bool = True
|
||||
port: int = Field(ge=1, le=65535)
|
||||
bind_address: str | None = None
|
||||
protocol: Literal["mqtt", "websockets"] = "mqtt"
|
||||
allow_anonymous: bool = False
|
||||
max_connections: int = -1
|
||||
tls_enabled: bool = False
|
||||
tls_min_version: Literal["tlsv1.2", "tlsv1.3"] | None = None
|
||||
require_certificate: bool = False
|
||||
use_identity_as_username: bool = False
|
||||
|
||||
|
||||
class ListenerOut(ListenerIn):
|
||||
id: uuid.UUID
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ConfigPreviewOut(BaseModel):
|
||||
config_text: str
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class DeployRequest(BaseModel):
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class RevisionOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
revision_number: int
|
||||
created_by: str | None
|
||||
comment: str | None
|
||||
deployed: bool
|
||||
deployed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RevisionDetailOut(RevisionOut):
|
||||
config_text: str
|
||||
validation_result: dict[str, Any]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Password hashing (Argon2id, spec section 43) and session tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher() # Argon2id by default
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception:
|
||||
# Malformed hash etc. — never crash authentication on it.
|
||||
return False
|
||||
|
||||
|
||||
def generate_password(length: int = 24) -> str:
|
||||
"""Cryptographically random password for MQTT clients (spec section 11)."""
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def generate_session_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def session_expiry(ttl_seconds: int) -> datetime:
|
||||
return datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Alembic environment — derives the URL from app settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
from app import models # noqa: F401 (register all models with Base)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
url = get_settings().database_url
|
||||
if url.startswith("postgresql://"):
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str] | None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str] | None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Release 1 initial schema.
|
||||
|
||||
Revision ID: r1_0001
|
||||
Revises:
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "r1_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str] | None] = None
|
||||
depends_on: Union[str, Sequence[str] | None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("username", sa.String(100), nullable=False, unique=True, index=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("display_name", sa.String(200), nullable=True),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum(
|
||||
"SUPER_ADMIN",
|
||||
"BROKER_ADMIN",
|
||||
"PKI_ADMIN",
|
||||
"OPERATOR",
|
||||
"AUDITOR",
|
||||
name="management_role",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column(
|
||||
"must_change_password", sa.Boolean(), nullable=False, server_default=sa.true()
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("modified_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Uuid(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"certificates",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("common_name", sa.String(200), nullable=False),
|
||||
sa.Column("certificate_pem", sa.Text(), nullable=False),
|
||||
sa.Column("private_key_pem", sa.Text(), nullable=True),
|
||||
sa.Column("expiry_date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"broker_listeners",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("port", sa.Integer(), nullable=False),
|
||||
sa.Column("bind_address", sa.String(100), nullable=True),
|
||||
sa.Column("protocol", sa.String(20), nullable=False),
|
||||
sa.Column("allow_anonymous", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("max_connections", sa.Integer(), nullable=False),
|
||||
sa.Column("tls_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("tls_min_version", sa.String(20), nullable=True),
|
||||
sa.Column(
|
||||
"require_certificate", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||
),
|
||||
sa.Column(
|
||||
"use_identity_as_username",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("cert_id", sa.Uuid(), sa.ForeignKey("certificates.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("modified_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"broker_settings",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("key", sa.String(100), nullable=False, unique=True, index=True),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("value_type", sa.String(20), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"broker_config_revisions",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("revision_number", sa.Integer(), nullable=False, unique=True, index=True),
|
||||
sa.Column("created_by", sa.String(100), nullable=True),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("config_text", sa.Text(), nullable=False),
|
||||
sa.Column("validation_result", sa.JSON(), nullable=False),
|
||||
sa.Column("deployed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"mqtt_client_metadata",
|
||||
sa.Column("username", sa.String(100), primary_key=True),
|
||||
sa.Column("display_name", sa.String(200), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("owner", sa.String(200), nullable=True),
|
||||
sa.Column("purpose", sa.String(200), nullable=True),
|
||||
sa.Column("tags", sa.JSON(), nullable=False),
|
||||
sa.Column("last_known_connection", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("modified_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"certificate_id",
|
||||
sa.Uuid(),
|
||||
sa.ForeignKey("certificates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
sa.Column("user", sa.String(100), nullable=True),
|
||||
sa.Column("source_ip", sa.String(64), nullable=True),
|
||||
sa.Column("action", sa.String(100), nullable=False, index=True),
|
||||
sa.Column("object_type", sa.String(100), nullable=True),
|
||||
sa.Column("object_id", sa.String(200), nullable=True),
|
||||
sa.Column("old_value", sa.JSON(), nullable=True),
|
||||
sa.Column("new_value", sa.JSON(), nullable=True),
|
||||
sa.Column("result", sa.String(20), nullable=False),
|
||||
sa.Column("correlation_id", sa.String(64), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("audit_events")
|
||||
op.drop_table("mqtt_client_metadata")
|
||||
op.drop_table("broker_config_revisions")
|
||||
op.drop_table("broker_settings")
|
||||
op.drop_table("broker_listeners")
|
||||
op.drop_table("certificates")
|
||||
op.drop_table("sessions")
|
||||
op.drop_table("users")
|
||||
op.execute("DROP TYPE IF EXISTS management_role")
|
||||
@@ -0,0 +1,35 @@
|
||||
[project]
|
||||
name = "mqtt-backend"
|
||||
version = "0.1.0"
|
||||
description = "MQTT Management Platform backend (Release 1)"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"sqlalchemy>=2.0",
|
||||
"psycopg[binary]>=3.2",
|
||||
"alembic>=1.14",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"argon2-cffi>=23.1",
|
||||
"paho-mqtt>=2.1",
|
||||
"python-multipart>=0.0.17",
|
||||
"httpx>=0.28",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"httpx>=0.28",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Shared pytest fixtures: sqlite-backed DB + FastAPI TestClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Ensure test settings before any app import resolves them.
|
||||
os.environ.setdefault("SESSION_SECRET", "test-session-secret-value-123456")
|
||||
os.environ.setdefault("SECRET_ENCRYPTION_KEY", "test-encryption-key-value-1234")
|
||||
os.environ.setdefault("ADMIN_INITIAL_PASSWORD", "test-admin-password-123")
|
||||
os.environ.setdefault("MOSQUITTO_DYNSEC_PASSWORD", "test-dynsec-password")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import Session, sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
from app import db as db_module # noqa: E402
|
||||
from app import main as main_module # noqa: E402
|
||||
from app.models import ManagementRole, ManagementUser # noqa: E402
|
||||
from app.security import hash_password # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session():
|
||||
"""Fresh in-memory database per test with schema + seeded users.
|
||||
|
||||
StaticPool + check_same_thread=False: the TestClient serves requests
|
||||
on a different thread than the fixture, but all access must share ONE
|
||||
sqlite connection (in-memory DBs are per-connection).
|
||||
"""
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
future=True,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
db_module.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, future=True)
|
||||
session = factory()
|
||||
# Seed one super admin and one auditor for RBAC tests.
|
||||
session.add_all(
|
||||
[
|
||||
ManagementUser(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin-password-123"),
|
||||
role=ManagementRole.SUPER_ADMIN,
|
||||
must_change_password=False,
|
||||
),
|
||||
ManagementUser(
|
||||
username="auditor",
|
||||
password_hash=hash_password("auditor-password-123"),
|
||||
role=ManagementRole.AUDITOR,
|
||||
must_change_password=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
@pytest.fixture()
|
||||
def client(db_session, monkeypatch):
|
||||
"""TestClient wired to the fixture DB via dependency_overrides."""
|
||||
|
||||
def _override_get_db():
|
||||
# Yield the SAME session the test asserts against; the request
|
||||
# handler shares its transaction for simplicity in tests.
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
pass # session lifetime managed by the db_session fixture
|
||||
|
||||
main_module.app.dependency_overrides[db_module.get_db] = _override_get_db
|
||||
|
||||
# Skip the lifespan bootstrap (create_all + admin seed) against a
|
||||
# separate engine: tests manage the schema themselves.
|
||||
from app import main as _m
|
||||
|
||||
async def _noop_lifespan(app):
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(_m, "_bootstrap_admin", lambda: None)
|
||||
|
||||
# Fake dynsec so no broker is needed for API tests.
|
||||
class FakeDynsec:
|
||||
def __init__(self):
|
||||
self.clients: dict[str, dict] = {}
|
||||
self.roles: dict[str, dict] = {}
|
||||
self.groups: dict[str, dict] = {}
|
||||
|
||||
def _command(self, command, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
def create_client(self, username, password, **kwargs):
|
||||
self.clients[username] = {"username": username, "enabled": True, **kwargs}
|
||||
|
||||
def list_clients(self):
|
||||
return list(self.clients.values())
|
||||
|
||||
def delete_client(self, username):
|
||||
self.clients.pop(username, None)
|
||||
|
||||
def disable_client(self, username):
|
||||
self.clients[username]["enabled"] = False
|
||||
|
||||
def enable_client(self, username):
|
||||
self.clients[username]["enabled"] = True
|
||||
|
||||
def set_client_password(self, username, password):
|
||||
pass
|
||||
|
||||
def get_client(self, username):
|
||||
return self.clients[username]
|
||||
|
||||
def create_group(self, groupname, roles=None):
|
||||
self.groups[groupname] = {"groupname": groupname, "roles": roles or []}
|
||||
|
||||
def list_groups(self):
|
||||
return list(self.groups.values())
|
||||
|
||||
def delete_group(self, groupname):
|
||||
self.groups.pop(groupname, None)
|
||||
|
||||
def create_role(self, rolename, acls=None):
|
||||
self.roles[rolename] = {"rolename": rolename, "acls": acls or []}
|
||||
|
||||
def list_roles(self):
|
||||
return list(self.roles.values())
|
||||
|
||||
def delete_role(self, rolename):
|
||||
self.roles.pop(rolename, None)
|
||||
|
||||
def set_role_acls(self, rolename, acls):
|
||||
self.roles[rolename]["acls"] = acls
|
||||
|
||||
def get_role(self, rolename):
|
||||
return self.roles[rolename]
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
fake = FakeDynsec()
|
||||
monkeypatch.setattr(main_module, "dynsec_client", fake)
|
||||
|
||||
from app.routers import mqtt as mqtt_router
|
||||
|
||||
monkeypatch.setattr(mqtt_router, "dynsec_client", fake)
|
||||
|
||||
with TestClient(main_module.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
main_module.app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def login(client: TestClient, username: str, password: str):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login", json={"username": username, "password": password}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client(client):
|
||||
login(client, "admin", "admin-password-123")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auditor_client(client):
|
||||
login(client, "auditor", "auditor-password-123")
|
||||
return client
|
||||
@@ -0,0 +1,130 @@
|
||||
"""API tests: authentication, RBAC, client + ACL flows (spec section 64)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.conftest import login
|
||||
|
||||
|
||||
def test_health_live(client):
|
||||
response = client.get("/health/live")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_login_sets_cookie_and_session(client):
|
||||
response = login(client, "admin", "admin-password-123")
|
||||
assert response.json()["role"] == "super_admin"
|
||||
|
||||
session = client.get("/api/v1/auth/session")
|
||||
assert session.status_code == 200
|
||||
assert session.json()["username"] == "admin"
|
||||
|
||||
|
||||
def test_login_wrong_password_audited(client):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_unauthenticated_api_rejected(client):
|
||||
response = client.get("/api/v1/mqtt/clients")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_auditor_cannot_create_clients(auditor_client):
|
||||
response = auditor_client.post(
|
||||
"/api/v1/mqtt/clients", json={"username": "ps042"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_create_client_returns_password_once(admin_client):
|
||||
response = admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["username"] == "ps042"
|
||||
assert len(body["password"]) >= 20 # random password generated
|
||||
|
||||
listing = admin_client.get("/api/v1/mqtt/clients")
|
||||
assert any(c["username"] == "ps042" for c in listing.json())
|
||||
|
||||
|
||||
def test_disable_enable_delete_client(admin_client):
|
||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "sensor-1"})
|
||||
|
||||
response = admin_client.post("/api/v1/mqtt/clients/sensor-1/disable")
|
||||
assert response.status_code == 204
|
||||
clients = {c["username"]: c for c in admin_client.get("/api/v1/mqtt/clients").json()}
|
||||
assert clients["sensor-1"]["enabled"] is False
|
||||
|
||||
assert admin_client.post("/api/v1/mqtt/clients/sensor-1/enable").status_code == 204
|
||||
|
||||
response = admin_client.delete("/api/v1/mqtt/clients/sensor-1")
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_role_acl_lifecycle(admin_client):
|
||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||
|
||||
acls = [
|
||||
{
|
||||
"acltype": "publishClientSend",
|
||||
"topic": "site/%u/telemetry/#",
|
||||
"allow": True,
|
||||
"priority": 0,
|
||||
},
|
||||
{
|
||||
"acltype": "subscribePattern",
|
||||
"topic": "site/%u/config/#",
|
||||
"allow": True,
|
||||
"priority": 1,
|
||||
},
|
||||
]
|
||||
response = admin_client.put("/api/v1/mqtt/roles/telemetry", json={"acls": acls})
|
||||
assert response.status_code == 200
|
||||
|
||||
roles = {r["rolename"]: r for r in admin_client.get("/api/v1/mqtt/roles").json()}
|
||||
assert roles["telemetry"]["acls"] == acls
|
||||
|
||||
|
||||
def test_group_lifecycle(admin_client):
|
||||
response = admin_client.post(
|
||||
"/api/v1/mqtt/groups", json={"groupname": "sensors"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
groups = {g["groupname"]: g for g in admin_client.get("/api/v1/mqtt/groups").json()}
|
||||
assert "sensors" in groups
|
||||
|
||||
assert admin_client.delete("/api/v1/mqtt/groups/sensors").status_code == 204
|
||||
|
||||
|
||||
def test_listener_management_and_preview(admin_client):
|
||||
response = admin_client.post(
|
||||
"/api/v1/broker/listeners",
|
||||
json={"name": "Production MQTT", "port": 1883, "protocol": "mqtt"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
listener_id = response.json()["id"]
|
||||
|
||||
preview = admin_client.get("/api/v1/broker/config/preview")
|
||||
assert preview.status_code == 200
|
||||
config_text = preview.json()["config_text"]
|
||||
assert "listener 1883" in config_text
|
||||
# Generated config must not use options deprecated in 2.1 (spec 3.1).
|
||||
assert "password_file" not in config_text
|
||||
assert "acl_file" not in config_text
|
||||
assert "per_listener_settings false" in config_text
|
||||
|
||||
delete = admin_client.delete(f"/api/v1/broker/listeners/{listener_id}")
|
||||
assert delete.status_code == 204
|
||||
|
||||
|
||||
def test_audit_events_recorded(admin_client, db_session):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
|
||||
actions = db_session.scalars(select(AuditEvent.action)).all()
|
||||
assert "CLIENT_CREATED" in actions
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Config generator unit tests (spec section 64: configuration generation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.configgen import generate_config
|
||||
from app.models import BrokerSettings, ListenerConfig
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(db_session):
|
||||
yield db_session
|
||||
|
||||
|
||||
def test_generate_with_one_listener(session):
|
||||
session.add(
|
||||
ListenerConfig(
|
||||
name="Production MQTT",
|
||||
port=1883,
|
||||
protocol="mqtt",
|
||||
allow_anonymous=False,
|
||||
max_connections=-1,
|
||||
tls_enabled=False,
|
||||
require_certificate=False,
|
||||
use_identity_as_username=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "listener 1883" in result.config_text
|
||||
assert "per_listener_settings false" in result.config_text
|
||||
assert "global_plugin /usr/lib/mosquitto_dynamic_security.so" in result.config_text
|
||||
assert "allow_anonymous false" in result.config_text
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_generate_includes_global_settings(session):
|
||||
session.add(ListenerConfig(name="l", port=1883, protocol="mqtt"))
|
||||
session.add(BrokerSettings(key="max_packet_size", value="2000000", value_type="int"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "max_packet_size 2000000" in result.config_text
|
||||
|
||||
|
||||
def test_generate_refuses_without_listeners(session):
|
||||
with pytest.raises(ValueError, match="no enabled listeners"):
|
||||
generate_config(session)
|
||||
|
||||
|
||||
def test_disabled_listeners_are_omitted(session):
|
||||
session.add(ListenerConfig(name="off", port=1884, protocol="mqtt", enabled=False))
|
||||
session.add(ListenerConfig(name="on", port=1883, protocol="mqtt"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "listener 1883" in result.config_text
|
||||
assert "listener 1884" not in result.config_text
|
||||
|
||||
|
||||
def test_tls_listener_without_pki_warns(session):
|
||||
session.add(
|
||||
ListenerConfig(
|
||||
name="tls-later",
|
||||
port=8883,
|
||||
protocol="mqtt",
|
||||
tls_enabled=True,
|
||||
tls_min_version="tlsv1.2",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert any("PKI is not available" in w for w in result.warnings)
|
||||
assert "tls_version tlsv1.2" in result.config_text
|
||||
Reference in New Issue
Block a user