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
+47
View File
@@ -0,0 +1,47 @@
# MQTT Management Platform — example environment file
# Copy to .env and fill in real values. NEVER commit .env to Git.
APP_ENV=development
# --- Management application administrator (spec §7.1) ---
# Only used during initial backend bootstrap; ignored once an admin exists.
ADMIN_INITIAL_USERNAME=admin
ADMIN_INITIAL_PASSWORD=change-me-admin
# --- PostgreSQL ---
DATABASE_USER=mqttadmin
DATABASE_PASSWORD=change-me-db
DATABASE_NAME=mqttadmin
# DATABASE_HOST / DATABASE_PORT are fixed inside compose (mqtt-db:5432);
# listed for local dev runs outside compose.
DATABASE_HOST=mqtt-db
DATABASE_PORT=5432
# --- Secrets ---
# 32+ byte random strings. Generate e.g.:
# openssl rand -hex 32
SESSION_SECRET=change-me-session-secret-32-bytes-min
SECRET_ENCRYPTION_KEY=change-me-encryption-key-32-bytes
# --- Mosquitto dynamic security bootstrap (spec §7) ---
# Consumed by the dynsec plugin on first broker start (2.1 feature).
MOSQUITTO_DYNSEC_USERNAME=admin
MOSQUITTO_DYNSEC_PASSWORD=change-me-dynsec
# --- Broker connection (backend -> mosquitto, internal network) ---
MOSQUITTO_HOST=mosquitto
MOSQUITTO_MGMT_PORT=1883
# --- Control sidecar ---
# Shared secret between backend and mosquitto-control (backend-net only).
CONTROL_API_TOKEN=change-me-control-token
# --- TLS / PKI defaults (used from Release 2 onwards) ---
DEFAULT_TLS_MIN_VERSION=tlsv1.2
DEFAULT_CERT_VALIDITY_DAYS=365
DEFAULT_CA_VALIDITY_DAYS=3650
# --- Frontend ---
# Non-secret build arg only; see compose.yaml (spec §7.2).
VITE_API_BASE_URL=/api/v1
FRONTEND_PORT=8080
+24
View File
@@ -0,0 +1,24 @@
# Environment files with secrets
.env
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
# Node
node_modules/
dist/
# Generated mosquitto configuration & secrets are never committed (spec §67)
mosquitto/config/generated/
*.key
*.pem
*.crt
*.p12
# OS / editor
.DS_Store
Thumbs.db
+42
View File
@@ -0,0 +1,42 @@
# MQTT Management Platform — Release 1 developer commands
# All commands assume a POSIX shell; on Windows use WSL/Git Bash or run the
# docker compose commands directly in PowerShell/cmd.
COMPOSE ?= docker compose
B ?= mqtt-backend
.PHONY: up down logs ps bootstrap migrate revision backend-shell test fmt lint
# --- Lifecycle -------------------------------------------------------------
up: ## Start the whole stack in the background
$(COMPOSE) up -d --build
down: ## Stop the stack (volumes are preserved)
$(COMPOSE) down
ps: ## Show service status
$(COMPOSE) ps
logs: ## Tail all logs
$(COMPOSE) logs -f
# --- Database --------------------------------------------------------------
migrate: ## Apply Alembic migrations inside the running backend
$(COMPOSE) exec $(B) alembic upgrade head
revision: ## Create an empty Alembic revision (autogenerate)
$(COMPOSE) exec $(B) alembic revision --autogenerate -m "$(m)"
backend-shell: ## Shell into the backend container
$(COMPOSE) exec $(B) sh
# --- Tests -----------------------------------------------------------------
test: ## Run backend test suite inside the container
$(COMPOSE) run --rm $(B) pytest -q
# --- Quality ---------------------------------------------------------------
fmt: ## Format backend sources
$(COMPOSE) run --rm $(B) ruff format app tests
lint: ## Lint backend sources
$(COMPOSE) run --rm $(B) ruff check app tests
+95
View File
@@ -0,0 +1,95 @@
# MQTT Management Platform — Release 1
Web-based administration for an Eclipse Mosquitto 2.1 broker: management
login/RBAC, MQTT client/group/role management with ACL editing via the
Dynamic Security plugin, and generated + validated broker configuration.
Architecture and scope follow `specification.md` (Release 1, §68.1).
## Stack
```text
mqtt-frontend React (Vite build) served by nginx, proxies /api → backend
mqtt-backend FastAPI: management auth, RBAC, dynsec client, config engine
mosquitto Eclipse Mosquitto 2.1.2 (pinned), dynsec enabled
mosquitto-control control sidecar: validate/activate/reload configs
mqtt-db PostgreSQL (management data only; NOT host-exposed)
```
Networks: `frontend-net` (frontend↔backend) and `backend-net`
(backend↔db↔mosquitto↔control). Only the frontend HTTP port is published.
## Quick start
```bash
cp .env.example .env # then EDIT the change-me values!
docker compose up -d --build
```
Open http://localhost:8080 and sign in with `ADMIN_INITIAL_USERNAME` /
`ADMIN_INITIAL_PASSWORD` from your `.env`.
Bootstrap sequence on first start:
1. PostgreSQL starts; backend creates its schema (or run migrations
explicitly, see below) and seeds the management admin.
2. Mosquitto starts with `mosquitto/bootstrap/mosquitto.conf` on the
internal network; the dynsec plugin seeds its `admin` client from
`MOSQUITTO_DYNSEC_PASSWORD`.
3. Backend connects to dynsec as that admin client — the management
channel is alive.
4. Configuration changes made in the UI are generated from the
PostgreSQL model, validated with `mosquitto --test-config` (via the
control sidecar), deployed atomically, and recorded as a revision.
> **Note for Windows users:** the compose healthcheck for mosquitto and
> the control sidecar rely on Unix facilities (`pid: service:`, POSIX
> shell commands); run the stack with Docker Desktop + WSL2 as usual —
> nothing here assumes a Linux host beyond Docker itself.
## Configuration channels (why this design)
* **Dynamic Security (runtime)** — clients/groups/roles/ACLs change via
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
* **Generated mosquitto.conf (rare)** — listeners/global settings are
generated from the DB model and deployed through `mosquitto-control`,
which shares the broker's PID namespace (`pid: "service:mosquitto"`)
to signal reloads. No Docker socket is mounted anywhere (spec §29).
* Mosquitto 2.1's HTTP API is **not** used as a management channel: it
has no authentication and (currently) exposes read-only plugin info.
## Development
Backend (in container):
```bash
docker compose exec mqtt-backend pytest -q # API + generator tests
docker compose exec mqtt-backend ruff check app tests
docker compose exec mqtt-backend alembic upgrade head # explicit migrations
```
Frontend (local dev server with proxy to backend):
```bash
cd frontend && npm install && npm run dev # http://localhost:5173
```
## Repository layout
```text
compose.yaml stack definition (5 services, 2 networks)
.env.example environment template (secrets: edit before use)
mosquitto/bootstrap/ the one file that ships with the deployment
broker-control/ validate / atomic-activate / reload sidecar
backend/ FastAPI app, models, dynsec client, configgen
frontend/ React + Vite SPA served by nginx
```
## Release 1 scope (see specification.md §68.1)
Included: management authentication & RBAC, dynsec clients/groups/roles
with the six ACL types, config generation from the PostgreSQL model,
validation, deployment with revision history.
Not yet (Release 2/3): PKI/certificates, mTLS listeners, diff view,
rollback, ACL simulator, dashboard/$SYS, audit browsing UI, backup.
+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
+39
View File
@@ -0,0 +1,39 @@
# broker-control — internal control sidecar (spec §29)
#
# Runs in the mosquitto container's PID namespace (compose `pid: "service:mosquitto"`)
# so it can signal the broker process (PID 1) without docker.sock access.
# The backend calls its HTTP API over the backend-net network.
#
# Responsibilities (deliberately minimal, spec §29):
# - validate staged configuration (mosquitto -c <staged> --test-config)
# - activate a configuration revision (atomic rename into place)
# - signal config reload (mosquitto_signal -p 1 config-reload == SIGHUP)
# - check broker process state
#
# Release 1 ships validate/activate/reload/status only; restarts are a
# Release 2 concern (reload-vs-restart classification, spec §28).
FROM eclipse-mosquitto:2.1.2-alpine
# Same image as the broker → --test-config validates with the exact
# broker binary that will load the config.
# The base image ships NO Python. control.py is stdlib-only, so the
# distro package is sufficient (no pip, no venv needed).
RUN apk add --no-cache python3
COPY control.py /app/control.py
# The base image has no USER directive, so this sidecar runs as root.
# Deliberate: it writes /mosquitto/config (owned by uid 1883 in the fresh
# volume) and signals the broker via the shared PID namespace. The API
# is token-guarded, internal-network-only, and no docker.sock is mounted
# (spec §29).
# Clear the CMD inherited from the base image — its default CMD
# (`mosquitto -c ...`) would otherwise be appended to our entrypoint.
CMD []
EXPOSE 8090
ENTRYPOINT ["python3", "/app/control.py"]
+250
View File
@@ -0,0 +1,250 @@
"""broker-control: minimal internal control API for Mosquitto.
Scope (spec section 29):
POST /deploy { "content": "<mosquitto.conf text>" } -> validate + atomic activate + reload
POST /reload -> signal config reload
GET /status -> broker process state
GET /health/live -> liveness
Security model:
* Listens on 0.0.0.0:8090 but is ONLY reachable on the backend-net
Docker network; the port is not published to the host.
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
* Never mounts docker.sock; signals the broker over the shared PID
namespace (broker is PID 1 in this container's view).
Implementation notes:
* Atomic activation writes content to a temp file in the SAME
directory as the target, then os.replace() renames it over the old
file. os.replace is atomic and is NOT affected by Mosquitto 2.1's
symlink-read restrictions (which we deliberately do not relax via
MOSQUITTO_UNSAFE_ALLOW_SYMLINKS).
* The broker runs as uid/gid 1883 in the official image, and so does
this sidecar (inherited from the base image), so file ownership is
consistent.
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOKEN = os.environ.get("CONTROL_API_TOKEN", "")
CONFIG_DIR = os.environ.get("MOSQUITTO_CONFIG_DIR", "/mosquitto/config")
ACTIVE_CONF = os.path.join(CONFIG_DIR, "mosquitto.conf")
STAGE_DIR = os.path.join(CONFIG_DIR, "staged")
LISTEN_PORT = int(os.environ.get("CONTROL_LISTEN_PORT", "8090"))
_LOCK = threading.Lock()
class _Error(Exception):
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def _validate(content: str) -> dict[str, object]:
"""Run `mosquitto -c <staged> --test-config` against staged content."""
os.makedirs(STAGE_DIR, exist_ok=True)
# Temp file inside STAGE_DIR so --test-config sees the same filesystem
# (relative includes, cert paths etc. behave as in production).
fd, path = tempfile.mkstemp(prefix="validate-", suffix=".conf", dir=STAGE_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
proc = subprocess.run(
["/usr/sbin/mosquitto", "-c", path, "--test-config"],
capture_output=True,
text=True,
timeout=30,
)
return {
"valid": proc.returncode == 0,
"stdout": proc.stdout,
"stderr": proc.stderr,
}
except subprocess.TimeoutExpired as exc:
return {"valid": False, "stdout": "", "stderr": f"validation timed out: {exc}"}
finally:
try:
os.unlink(path)
except OSError:
pass
def _activate(content: str) -> None:
"""Atomically replace the active mosquitto.conf with validated content."""
os.makedirs(os.path.dirname(ACTIVE_CONF), exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".mosquitto.conf.", dir=CONFIG_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
# 0644: broker (uid 1883) must be able to read; same-uid sidecar writes it.
os.chmod(tmp, 0o644)
os.replace(tmp, ACTIVE_CONF)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _signal_reload() -> None:
"""Signal the broker (PID 1 in the shared PID namespace) to reload."""
try:
proc = subprocess.run(
["mosquitto_signal", "-p", "1", "config-reload"],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
# Fallback if the helper app is unavailable for any reason.
os.kill(1, signal.SIGHUP)
return
if proc.returncode != 0:
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
def _broker_pid_alive() -> bool:
try:
# Broker is PID 1 in the shared PID namespace.
with open("/proc/1/comm", encoding="utf-8") as f:
return f.read().strip() == "mosquitto"
except OSError:
return False
def _post_broker_health() -> bool:
"""Cheap post-signal health probe: subscribe to $SYS/broker/version."""
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
cmd = [
"mosquitto_sub",
"-h",
"127.0.0.1",
"-u",
username,
"-P",
token_pw,
"-t",
"$SYS/broker/version",
"-C",
"1",
"-W",
"3",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return proc.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
class Handler(BaseHTTPRequestHandler):
server_version = "mosquitto-control/0.1"
# -- plumbing -----------------------------------------------------------
def _send(self, status: int, payload: dict[str, object]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _authorized(self) -> bool:
if not TOKEN:
return False # refuse to run unauthenticated
header = self.headers.get("Authorization", "")
return header == f"Bearer {TOKEN}"
def _read_json(self) -> dict[str, object]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 2_000_000:
raise _Error(400, "missing or oversized body")
try:
data = json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise _Error(400, f"invalid JSON body: {exc}") from exc
if not isinstance(data, dict):
raise _Error(400, "JSON body must be an object")
return data
def log_message(self, fmt: str, *args: object) -> None:
sys.stderr.write("[control] " + (fmt % args) + "\n")
# -- routes -------------------------------------------------------------
def do_GET(self) -> None:
if self.path == "/health/live":
self._send(200, {"status": "ok"})
return
if not self._authorized():
self._send(401, {"error": "unauthorized"})
return
if self.path == "/status":
self._send(
200,
{
"broker_pid_alive": _broker_pid_alive(),
"config_file": ACTIVE_CONF,
},
)
return
self._send(404, {"error": "not found"})
def do_POST(self) -> None:
if not self._authorized():
self._send(401, {"error": "unauthorized"})
return
try:
if self.path == "/deploy":
self._handle_deploy()
elif self.path == "/reload":
_signal_reload()
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
else:
self._send(404, {"error": "not found"})
except _Error as exc:
self._send(exc.status, {"error": exc.message})
except OSError as exc:
self._send(500, {"error": f"os error: {exc}"})
def _handle_deploy(self) -> None:
body = self._read_json()
content = body.get("content")
if not isinstance(content, str) or not content.strip():
raise _Error(400, "field 'content' (mosquitto.conf text) is required")
with _LOCK:
result = _validate(content)
if not result["valid"]:
self._send(422, {"status": "validation_failed", **result})
return
_activate(content)
_signal_reload()
self._send(
200,
{"status": "deployed", "broker_healthy": _post_broker_health()},
)
def main() -> None:
if not TOKEN:
sys.stderr.write("[control] FATAL: CONTROL_API_TOKEN is not set\n")
sys.exit(1)
server = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
sys.stderr.write(f"[control] listening on :{LISTEN_PORT}\n")
server.serve_forever()
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
name: mqtt-platform
# MQTT Management Platform — Release 1
# See specification.md §4, §5, §29, §60 for architecture decisions.
x-backend-env: &backend-env
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: postgresql+psycopg://${DATABASE_USER:-mqttadmin}:${DATABASE_PASSWORD:-changeme}@${DATABASE_HOST:-mqtt-db}:${DATABASE_PORT:-5432}/${DATABASE_NAME:-mqttadmin}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:?SECRET_ENCRYPTION_KEY must be set}
ADMIN_INITIAL_USERNAME: ${ADMIN_INITIAL_USERNAME:-admin}
ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD must be set}
MOSQUITTO_HOST: ${MOSQUITTO_HOST:-mosquitto}
MOSQUITTO_MGMT_PORT: ${MOSQUITTO_MGMT_PORT:-1883}
MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin}
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650}
services:
mqtt-db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DATABASE_USER:-mqttadmin}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-changeme}
POSTGRES_DB: ${DATABASE_NAME:-mqttadmin}
volumes:
- mqtt_db_data:/var/lib/postgresql/data
# Port deliberately NOT published to the host (spec §5.2).
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-mqttadmin} -d ${DATABASE_NAME:-mqttadmin}"]
interval: 5s
timeout: 3s
retries: 12
mosquitto:
image: eclipse-mosquitto:2.1.2-alpine
restart: unless-stopped
# Spec §29: no docker.sock. Control sidecar shares the broker's PID namespace
# and the mosquitto_config volume; it signals PID 1 (the broker) directly.
environment:
# Used by the dynsec plugin on first start to seed the 'admin' client
# (verified: Mosquitto 2.1 dynamic security docs).
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
volumes:
- mosquitto_config:/mosquitto/config
- mosquitto_data:/mosquitto/data
- mosquitto_log:/mosquitto/log
networks:
- backend-net
# Release 1 ships an internal-only, plaintext listener on the backend
# network for the management channel; production listeners are added
# through the platform (spec §60). Nothing is published to the host.
# Healthcheck: dynsec denies anonymous clients, so we must connect as
# the seeded 'admin' (password from env, $$ = escaped for compose).
healthcheck:
test: ["CMD-SHELL", "mosquitto_sub -h 127.0.0.1 -u admin -P \"$$MOSQUITTO_DYNSEC_PASSWORD\" -t '$$SYS/broker/version' -C 1 -W 3 >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
mosquitto-control:
build:
context: ./broker-control
restart: unless-stopped
depends_on:
mosquitto:
condition: service_started
# Shares PID namespace with the broker so `mosquitto_signal -p 1 ...`
# reaches the broker process without mounting docker.sock.
pid: "service:mosquitto"
environment:
# Minimal default token for the internal control API; in production
# this MUST be replaced (it only guards a backend-net-only endpoint).
CONTROL_API_TOKEN: ${CONTROL_API_TOKEN:?CONTROL_API_TOKEN must be set}
# Dynsec admin credentials for the post-deploy broker health probe
# (mosquitto_sub against $SYS/broker/version on loopback).
MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin}
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
MOSQUITTO_CONFIG_DIR: /mosquitto/config
volumes:
- mosquitto_config:/mosquitto/config
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/health/live"]
interval: 10s
timeout: 3s
retries: 12
start_period: 5s
mqtt-backend:
build:
context: ./backend
restart: unless-stopped
depends_on:
mqtt-db:
condition: service_healthy
mosquitto:
condition: service_healthy
mosquitto-control:
condition: service_healthy
environment:
<<: *backend-env
volumes:
- backend_data:/data
networks:
- frontend-net
- backend-net
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=3)\""]
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
mqtt-frontend:
build:
context: ./frontend
args:
# Only non-secret build args cross the frontend boundary (spec §7.2).
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api/v1}
restart: unless-stopped
depends_on:
mqtt-backend:
condition: service_healthy
networks:
- frontend-net
ports:
- "${FRONTEND_PORT:-8080}:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:80/ || exit 1"]
interval: 10s
timeout: 3s
retries: 12
volumes:
mqtt_db_data:
mosquitto_config:
mosquitto_data:
mosquitto_log:
backend_data:
networks:
frontend-net:
backend-net:
+16
View File
@@ -0,0 +1,16 @@
# Build stage (spec section 3.3: no privileged credentials in the frontend;
# VITE_API_BASE_URL is the ONLY build arg and contains no secret).
FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# Serve stage: nginx proxies /api to the backend over frontend-net.
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MQTT Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA routing: serve index.html for any non-API, non-asset path.
location / {
try_files $uri $uri/ /index.html;
}
# REST API proxy (spec section 4: frontend -> backend over frontend-net).
location /api/ {
proxy_pass http://mqtt-backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "mqtt-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "./auth";
import { LoginPage } from "./pages/Login";
import { ClientsPage } from "./pages/Clients";
import { RolesPage } from "./pages/Roles";
import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig";
function Layout({ children }: { children: React.ReactNode }) {
const { session, logout } = useAuth();
return (
<div className="layout">
<aside className="sidebar">
<h1>MQTT Management</h1>
<nav>
<NavLink to="/clients">Clients</NavLink>
<NavLink to="/groups">Groups</NavLink>
<NavLink to="/roles">Roles &amp; ACLs</NavLink>
<NavLink to="/broker">Broker configuration</NavLink>
</nav>
<p style={{ marginTop: "2rem", fontSize: "0.8rem", opacity: 0.7 }}>
{session?.username} ({session?.role})
</p>
<button onClick={() => void logout()}>Sign out</button>
</aside>
<main className="content">{children}</main>
</div>
);
}
function Protected({ children }: { children: React.ReactNode }) {
const { session, loading } = useAuth();
if (loading) return <p>Loading…</p>;
if (!session) return <Navigate to="/login" replace />;
return <Layout>{children}</Layout>;
}
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/clients"
element={
<Protected>
<ClientsPage />
</Protected>
}
/>
<Route
path="/groups"
element={
<Protected>
<GroupsPage />
</Protected>
}
/>
<Route
path="/roles"
element={
<Protected>
<RolesPage />
</Protected>
}
/>
<Route
path="/broker"
element={
<Protected>
<BrokerConfigPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} />
</Routes>
);
}
+116
View File
@@ -0,0 +1,116 @@
// Thin API client around fetch with session cookies (spec section 8:
// HttpOnly cookies, never tokens in localStorage).
const BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
if (!response.ok) {
let message = response.statusText;
try {
const body = await response.json();
message = body.detail ?? body.error ?? message;
} catch {
/* non-JSON error body */
}
throw new ApiError(response.status, message);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "PUT", body: body === undefined ? undefined : JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: "PATCH", body: body === undefined ? undefined : JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
// --- Types (mirrors backend schemas) ---------------------------------------
export interface SessionInfo {
username: string;
role: "super_admin" | "broker_admin" | "pki_admin" | "operator" | "auditor";
must_change_password: boolean;
}
export interface AclRule {
acltype: string;
topic: string;
allow: boolean;
priority: number;
}
export interface Role {
rolename: string;
text_name?: string | null;
text_description?: string | null;
acls: AclRule[];
}
export interface Group {
groupname: string;
text_name?: string | null;
roles: { rolename: string; priority: number }[];
}
export interface MqttClient {
username: string;
clientid: string | null;
text_name: string | null;
text_description: string | null;
enabled: boolean;
groups: { groupname: string; priority: number }[];
roles: { rolename: string; priority: number }[];
owner: string | null;
purpose: string | null;
tags: string[];
last_known_connection: string | null;
}
export interface PasswordReset {
username: string;
password: string;
}
export interface Listener {
id: string;
name: string;
enabled: boolean;
port: number;
bind_address: string | null;
protocol: "mqtt" | "websockets";
allow_anonymous: boolean;
max_connections: number;
tls_enabled: boolean;
tls_min_version: string | null;
require_certificate: boolean;
use_identity_as_username: boolean;
}
export interface Revision {
id: string;
revision_number: number;
created_by: string | null;
comment: string | null;
deployed: boolean;
deployed_at: string | null;
created_at: string;
}
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { api, type SessionInfo } from "./api";
interface AuthContextValue {
session: SessionInfo | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<SessionInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get<SessionInfo>("/auth/session")
.then(setSession)
.catch(() => setSession(null))
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (username: string, password: string) => {
setSession(await api.post<SessionInfo>("/auth/login", { username, password }));
}, []);
const logout = useCallback(async () => {
await api.post("/auth/logout");
setSession(null);
}, []);
return (
<AuthContext.Provider value={{ session, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./auth";
import { App } from "./App";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
+129
View File
@@ -0,0 +1,129 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { api, type Listener, type Revision } from "../api";
interface ConfigPreview {
config_text: string;
warnings: string[];
}
export function BrokerConfigPage() {
const listeners = useQuery({
queryKey: ["listeners"],
queryFn: () => api.get<Listener[]>("/broker/listeners"),
});
const preview = useQuery({
queryKey: ["config-preview"],
queryFn: () => api.get<ConfigPreview>("/broker/config/preview"),
});
const revisions = useQuery({
queryKey: ["revisions"],
queryFn: () => api.get<Revision[]>("/broker/revisions"),
});
const deploy = useMutation({
mutationFn: (comment: string | null) =>
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
"/broker/config/deploy",
{ comment },
),
onSuccess: () => {
preview.refetch();
revisions.refetch();
},
});
return (
<div>
<h2>Broker configuration</h2>
<p>
The configuration is generated from the platform model, validated with
<code> mosquitto --test-config</code>, deployed atomically, and every
deployment creates a revision.
</p>
<h3>Listeners</h3>
{listeners.isLoading ? (
<p>Loading…</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Port</th>
<th>Bind</th>
<th>Protocol</th>
<th>Anonymous</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
{(listeners.data ?? []).map((l) => (
<tr key={l.id}>
<td>{l.name}</td>
<td className="mono">{l.port}</td>
<td className="mono">{l.bind_address ?? "0.0.0.0"}</td>
<td>{l.protocol}</td>
<td>{l.allow_anonymous ? "yes" : "no"}</td>
<td>{l.enabled ? "yes" : "no"}</td>
</tr>
))}
</tbody>
</table>
)}
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
{preview.data?.warnings.map((w) => (
<div key={w} className="banner warn">
{w}
</div>
))}
<pre className="config mono">{preview.data?.config_text ?? "Generating…"}</pre>
<button
className="primary"
disabled={deploy.isPending}
onClick={() => {
const comment = prompt("Deployment comment (optional)");
deploy.mutate(comment);
}}
>
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
</button>
{deploy.data && (
<div className="banner ok">
Deployed revision {deploy.data.revision}. Broker health:{" "}
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
</div>
)}
{deploy.error && (
<div className="banner error">{(deploy.error as Error).message}</div>
)}
<h3 style={{ marginTop: "2rem" }}>Revisions</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Deployed</th>
<th>By</th>
<th>Comment</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{(revisions.data ?? []).map((r) => (
<tr key={r.id}>
<td className="mono">{r.revision_number}</td>
<td>{r.deployed ? new Date(r.deployed_at!).toLocaleString() : "—"}</td>
<td>{r.created_by ?? "—"}</td>
<td>{r.comment ?? "—"}</td>
<td>{new Date(r.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type MqttClient, type PasswordReset } from "../api";
export function ClientsPage() {
const queryClient = useQueryClient();
const [newUsername, setNewUsername] = useState("");
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
const [error, setError] = useState<string | null>(null);
const clients = useQuery({
queryKey: ["clients"],
queryFn: () => api.get<MqttClient[]>("/mqtt/clients"),
});
const create = useMutation({
mutationFn: (username: string) =>
api.post<PasswordReset>("/mqtt/clients", { username }),
onSuccess: (result) => {
setOncePassword(result);
setNewUsername("");
queryClient.invalidateQueries({ queryKey: ["clients"] });
},
onError: (e: Error) => setError(e.message),
});
const disable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
});
const enable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
});
const remove = useMutation({
mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (newUsername) create.mutate(newUsername);
};
return (
<div>
<h2>Clients</h2>
<p>
MQTT identities managed by the broker's dynamic security plugin. Passwords
are shown exactly once on creation or reset.
</p>
{oncePassword && (
<div className="banner warn">
<strong>Password for {oncePassword.username} — copy it now, it is shown
only once:</strong>
<div className="password-once mono">{oncePassword.password}</div>
</div>
)}
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="new-client">New client username</label>
<input
id="new-client"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="e.g. ps042"
/>
<p />
<button className="primary" disabled={!newUsername || create.isPending}>
{create.isPending ? "Creating…" : "Create client (random password)"}
</button>
</form>
{clients.isLoading ? (
<p>Loading clients…</p>
) : (
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Username</th>
<th>Client ID</th>
<th>Enabled</th>
<th>Groups</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(clients.data ?? []).map((c) => (
<tr key={c.username}>
<td className="mono">{c.username}</td>
<td className="mono">{c.clientid ?? "—"}</td>
<td>{c.enabled ? "yes" : "no"}</td>
<td>{c.groups.map((g) => g.groupname).join(", ") || "—"}</td>
<td>{c.roles.map((r) => r.rolename).join(", ") || "—"}</td>
<td>
{c.enabled ? (
<button onClick={() => disable.mutate(c.username)}>Disable</button>
) : (
<button onClick={() => enable.mutate(c.username)}>Enable</button>
)}{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete client ${c.username}?`))
remove.mutate(c.username);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Group } from "../api";
export function GroupsPage() {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const groups = useQuery({
queryKey: ["groups"],
queryFn: () => api.get<Group[]>("/mqtt/groups"),
});
const create = useMutation({
mutationFn: (groupname: string) => api.post("/mqtt/groups", { groupname }),
onSuccess: () => {
setName("");
queryClient.invalidateQueries({ queryKey: ["groups"] });
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["groups"] }),
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (name) create.mutate(name);
};
return (
<div>
<h2>Groups</h2>
<p>Assign roles to collections of clients (dynamic security groups).</p>
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="group-name">New group name</label>
<input
id="group-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. sensors"
/>
<p />
<button className="primary" disabled={!name || create.isPending}>
Create group
</button>
</form>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Group</th>
<th>Roles</th>
<th></th>
</tr>
</thead>
<tbody>
{(groups.data ?? []).map((g) => (
<tr key={g.groupname}>
<td className="mono">{g.groupname}</td>
<td>{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"}</td>
<td>
<button
className="danger"
onClick={() => {
if (confirm(`Delete group ${g.groupname}?`)) remove.mutate(g.groupname);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { FormEvent, useState } from "react";
import { useAuth } from "../auth";
export function LoginPage() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(username, password);
} catch {
setError("Invalid username or password.");
} finally {
setBusy(false);
}
};
return (
<form className="login-card" onSubmit={(e) => void submit(e)}>
<h2>MQTT Management</h2>
{error && <div className="banner error">{error}</div>}
<label htmlFor="username">Username</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<p />
<button className="primary" disabled={busy || !username || !password}>
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Role, type AclRule } from "../api";
const ACL_TYPES = [
"publishClientSend",
"publishClientReceive",
"subscribeLiteral",
"subscribePattern",
"unsubscribeLiteral",
"unsubscribePattern",
] as const;
export function RolesPage() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const roles = useQuery({
queryKey: ["roles"],
queryFn: () => api.get<Role[]>("/mqtt/roles"),
});
const detail = useQuery({
queryKey: ["role", selected],
queryFn: () => api.get<Role>(`/mqtt/roles/${selected}`),
enabled: selected !== null,
});
const create = useMutation({
mutationFn: (rolename: string) => api.post("/mqtt/roles", { rolename, acls: [] }),
onSuccess: () => {
setName("");
queryClient.invalidateQueries({ queryKey: ["roles"] });
},
onError: (e: Error) => setError(e.message),
});
const save = useMutation({
mutationFn: (role: Role) => api.put(`/mqtt/roles/${role.rolename}`, role),
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: ["role", selected] });
},
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (name) create.mutate(name);
};
const role = detail.data;
const updateAcl = (index: number, patch: Partial<AclRule>) => {
if (!role) return;
const acls = role.acls.map((a, i) => (i === index ? { ...a, ...patch } : a));
save.mutate({ ...role, acls });
};
const addAcl = () => {
if (!role) return;
save.mutate({
...role,
acls: [
...role.acls,
{ acltype: "publishClientSend", topic: "", allow: true, priority: 0 },
],
});
};
const removeAcl = (index: number) => {
if (!role) return;
save.mutate({ ...role, acls: role.acls.filter((_, i) => i !== index) });
};
return (
<div>
<h2>Roles &amp; ACLs</h2>
<p>
ACL types map to dynamic security: publishClientSend (client publishes),
publishClientReceive (client receives), subscribe/unsubscribe in literal
and pattern variants. <code>%u</code> and <code>%c</code> substitute the
username and client id.
</p>
{error && <div className="banner error">{error}</div>}
<form className="stack" onSubmit={submit}>
<label htmlFor="role-name">New role name</label>
<input
id="role-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. site-a-sensors"
/>
<p />
<button className="primary" disabled={!name || create.isPending}>
Create role
</button>
</form>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Role</th>
<th>ACLs</th>
<th>Open</th>
</tr>
</thead>
<tbody>
{(roles.data ?? []).map((r) => (
<tr key={r.rolename}>
<td className="mono">{r.rolename}</td>
<td>{(r.acls ?? []).length}</td>
<td>
<button onClick={() => setSelected(r.rolename)}>Edit ACLs</button>
</td>
</tr>
))}
</tbody>
</table>
{role && (
<section style={{ marginTop: "2rem" }}>
<h3>
Role: <span className="mono">{role.rolename}</span>
</h3>
<button onClick={addAcl} disabled={save.isPending}>
+ Add ACL rule
</button>
<table style={{ marginTop: "1rem" }}>
<thead>
<tr>
<th>Type</th>
<th>Topic filter</th>
<th>Effect</th>
<th>Priority</th>
<th></th>
</tr>
</thead>
<tbody>
{role.acls.map((acl, index) => (
<tr key={index}>
<td>
<select
value={acl.acltype}
onChange={(e) =>
updateAcl(index, { acltype: e.target.value as (typeof ACL_TYPES)[number] })
}
>
{ACL_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td>
<input
className="mono"
value={acl.topic}
placeholder="site/a/%u/telemetry/#"
onChange={(e) => updateAcl(index, { topic: e.target.value })}
/>
</td>
<td>
<select
value={acl.allow ? "allow" : "deny"}
onChange={(e) => updateAcl(index, { allow: e.target.value === "allow" })}
>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</td>
<td>
<input
type="number"
min={0}
max={100000}
value={acl.priority}
onChange={(e) =>
updateAcl(index, { priority: Number(e.target.value) })
}
/>
</td>
<td>
<button className="danger" onClick={() => removeAcl(index)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
{save.isPending && <p>Saving…</p>}
</section>
)}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
:root {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f4f5f7;
color: #1f2933;
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.sidebar {
background: #1f2933;
color: #f0f4f8;
padding: 1.25rem;
}
.sidebar h1 {
font-size: 1.05rem;
margin: 0 0 1.5rem;
}
.sidebar nav a {
display: block;
padding: 0.45rem 0.6rem;
margin: 0.1rem 0;
border-radius: 6px;
color: #cbd2d9;
text-decoration: none;
}
.sidebar nav a.active,
.sidebar nav a:hover {
background: #323f4b;
color: #fff;
}
.content {
padding: 1.5rem 2rem;
max-width: 1100px;
}
.content h2 {
margin-top: 0;
}
table {
border-collapse: collapse;
width: 100%;
background: #fff;
border-radius: 8px;
overflow: hidden;
}
th,
td {
text-align: left;
padding: 0.55rem 0.8rem;
border-bottom: 1px solid #e4e7eb;
font-size: 0.92rem;
}
th {
background: #f0f4f8;
}
button {
font: inherit;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid #cbd2d9;
background: #fff;
cursor: pointer;
}
button.primary {
background: #1f6feb;
border-color: #1f6feb;
color: #fff;
}
button.danger {
background: #fff;
border-color: #e5534b;
color: #e5534b;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
form.stack label,
label {
display: block;
margin: 0.6rem 0 0.2rem;
font-size: 0.85rem;
color: #52606d;
}
input,
select,
textarea {
font: inherit;
padding: 0.45rem 0.55rem;
border: 1px solid #cbd2d9;
border-radius: 6px;
width: 100%;
background: #fff;
}
.login-card {
max-width: 340px;
margin: 12vh auto;
background: #fff;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(31, 41, 51, 0.12);
}
.banner {
padding: 0.6rem 0.9rem;
border-radius: 6px;
margin: 0.6rem 0;
}
.banner.ok {
background: #e6f4ea;
color: #1e6b3a;
}
.banner.error {
background: #fdecea;
color: #a4342d;
}
.banner.warn {
background: #fff6e0;
color: #8a5a00;
}
.mono {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 0.85rem;
}
pre.config {
background: #1f2933;
color: #d5e3f0;
padding: 1rem;
border-radius: 8px;
overflow: auto;
max-height: 420px;
}
.password-once {
background: #fff6e0;
border: 1px solid #e3b341;
padding: 0.8rem 1rem;
border-radius: 8px;
font-family: ui-monospace, monospace;
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
// Typed access to the single non-secret frontend build arg (spec §7.2).
// Browser-visible env must NEVER contain passwords or secrets.
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Local dev: Vite on :5173 proxies API calls to the backend container.
"/api": "http://localhost:8000",
},
},
});
+45
View File
@@ -0,0 +1,45 @@
# mosquitto.conf — initial bootstrap configuration (Release 1)
#
# This file ships with the stack and starts the broker on the internal
# backend network only. It is the ONE file that is legitimately part of
# the deployment; from here on, all configuration changes go through the
# platform's revision pipeline (spec §25, §61).
#
# The generated platform configuration will replace this file through
# mosquitto-control, never by direct editing.
#
# NOTE (2.1 deprecations, spec §3.1): we use `plugin`/`global_plugin`,
# NOT the deprecated password_file/acl_file/per_listener_settings options.
per_listener_settings false
# --- Internal management listener (backend-net only) ---
# Plaintext is acceptable here because the network is isolated and the
# listener is not published to the host. Production TLS listeners are
# added via the platform (spec §60).
listener 1883 0.0.0.0
allow_anonymous false
# --- Dynamic Security plugin ---
plugin /usr/lib/mosquitto_dynamic_security.so
plugin_opt_config_file /mosquitto/config/dynamic-security.json
# Admin client password is seeded from MOSQUITTO_DYNSEC_PASSWORD on first
# start (2.1 feature); the .pw file fallback is not used because the env
# var is always provided by compose.
# --- Persistence (plugin-based, recommended over built-in in 2.1) ---
persistence true
persistence_location /mosquitto/data/
autosave_interval 900
# --- Telemetry for the future dashboard (Release 3) ---
sys_interval 10
# --- Logging ---
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
log_timestamp true
log_timestamp_format %Y-%m-%dT%H:%M:%S
+8
View File
@@ -0,0 +1,8 @@
# Minimal health-check client config used by `mosquitto_sub` in the
# compose healthcheck. It connects to the loopback listener anonymously
# only far enough to receive $SYS/broker/version — dynsec denies
# everything else for anonymous clients, which is fine: we only need the
# TCP + MQTT CONNECT handshake to succeed for the check.
#
# Kept separate from bootstrap/mosquitto.conf so the healthcheck does not
# depend on the generated/managed configuration at all.
+79 -41
View File
@@ -2,7 +2,7 @@
## Service Specification
**Status:** Initial specification
**Status:** Initial specification, revised 2026-09-15 (MVP split into three releases)
**Target deployment:** Docker Compose / Portainer Stack
**Primary broker:** Eclipse Mosquitto 2.1.x
**Architecture:** Mosquitto + custom backend + custom frontend + PostgreSQL
@@ -2134,14 +2134,18 @@ Generated configuration and secrets shall not be stored in Git.
---
# 68. MVP scope
# 68. MVP scope and release plan
The first functional release should include:
The original single "first release" list contained roughly 25 sub-systems and is not achievable as one release. The MVP is therefore split into three releases. Each release is independently deployable and useful, and each builds on the previous one.
## 68.1 Release 1 — Core broker administration
Goal: deliver the original core requirement — a web-managed Mosquitto broker with client authentication and authorization, including ACLs, without touching files on the host.
```text
Docker Compose stack
Mosquitto 2.1.x
Mosquitto 2.1.x (pinned)
PostgreSQL
@@ -2149,60 +2153,94 @@ FastAPI backend
React frontend
Management authentication
Management authentication (sessions, Argon2id, ADMIN_INITIAL_* bootstrap)
Management RBAC
Management RBAC (core roles)
Dynamic Security integration
Dynamic Security integration (backend as MQTT admin client)
MQTT clients
MQTT clients (create/disable/enable/delete, password reset, random passwords)
Groups
Roles
ACL editor
ACL editor (all six ACL types, %u/%c patterns, priorities)
ACL simulator
CA creation
Server certificates
Client certificates
CSR signing
Certificate revocation
CRL
Broker configuration editor
Broker configuration editor (basic + advanced mode)
Listener configuration
Configuration validation
Configuration validation (--test-config)
Configuration revisioning
Configuration deployment
Broker reload
Dashboard
$SYS monitoring
Audit logging
Backup/restore
Configuration generation from the PostgreSQL model
```
Release 1 delivers a secure default broker configuration and removes the need to edit
`mosquitto.conf`, the Dynamic Security JSON, or password files manually.
## 68.2 Release 2 — PKI, versioning, and controlled change
Goal: make TLS/mTLS and broker configuration changes safe and auditable.
```text
CA creation (internal CA, import CA)
Server certificate generation (CN, SANs, key algorithm/length)
Client certificates (managed-key mode, one-time download)
CSR signing
Certificate revocation and CRL generation/publishing
mTLS listeners (require_certificate, use_identity_as_username)
Configuration revisions (history, diff)
Configuration deployment with health check
Broker reload (mosquitto_signal / SIGHUP), reload-vs-restart classification
Configuration rollback (rollback creates a new revision)
mosquitto-control service (validate, activate, signal, health check)
```
## 68.3 Release 3 — Operations, safety, and visibility
Goal: make the platform usable for daily operation and secure change management.
```text
ACL simulator (with parity tests against real broker decisions)
Dashboard ($SYS monitoring, broker status)
Certificate expiry monitoring and dashboard warnings
Audit logging (all privileged actions)
Backup and restore (logical backup, encrypted)
Configuration drift detection
Client metadata/tags (owner, purpose, last known connection)
Connected-client view (where available)
```
## 68.4 Post-MVP
Topic explorer, MQTT test client, connection tester, client provisioning wizard,
bridge configuration, retained-message administration, Prometheus metrics, external
notifications, and the approval workflow remain post-MVP features as previously
planned (see sections 69 and 70).
---
# 69. Phase 2
# 69. Post-MVP: Phase 2
The second phase should add:
After the three MVP releases (section 68) are complete, the next phase should add:
```text
Topic explorer
@@ -2221,7 +2259,7 @@ External notification mechanisms
---
# 70. Phase 3
# 70. Post-MVP: Phase 3
Potential advanced functionality: