This commit is contained in:
Jonas Bengtsson
2026-09-16 06:08:37 +02:00
parent cce6a4960e
commit 8d2c54beae
54 changed files with 8480 additions and 41 deletions
+33
View File
@@ -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"]
+4
View File
@@ -0,0 +1,4 @@
[alembic]
script_location = migrations
prepend_sys_path = .
# URL is composed at runtime from environment (see env.py); not stored here.
+1
View File
@@ -0,0 +1 @@
"""Application package."""
+52
View File
@@ -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).
+52
View File
@@ -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()
+121
View File
@@ -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)
+53
View File
@@ -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()
+77
View File
@@ -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)]
+246
View File
@@ -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()
+133
View File
@@ -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)
+210
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
"""API routers package."""
+107
View File
@@ -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"}
+281
View File
@@ -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
+307
View File
@@ -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
+113
View File
@@ -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
+210
View File
@@ -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]
+40
View File
@@ -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)
+53
View File
@@ -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()
+24
View File
@@ -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")
+35
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Tests package."""
+179
View File
@@ -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
+130
View File
@@ -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
+77
View File
@@ -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