From 8d2c54beaec2658b50183e2bee825c3db526cf61 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Wed, 16 Sep 2026 06:08:37 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 47 + .gitignore | 24 + Makefile | 42 + README.md | 95 + backend/Dockerfile | 33 + backend/alembic.ini | 4 + backend/app/__init__.py | 1 + backend/app/audit.py | 52 + backend/app/config.py | 52 + backend/app/configgen.py | 121 + backend/app/db.py | 53 + backend/app/deps.py | 77 + backend/app/dynsec.py | 246 ++ backend/app/main.py | 133 + backend/app/models.py | 210 + backend/app/routers/__init__.py | 1 + backend/app/routers/auth.py | 107 + backend/app/routers/broker.py | 281 ++ backend/app/routers/mqtt.py | 307 ++ backend/app/routers/users.py | 113 + backend/app/schemas.py | 210 + backend/app/security.py | 40 + backend/migrations/env.py | 53 + backend/migrations/script.py.mako | 24 + .../migrations/versions/r1_0001_initial.py | 161 + backend/pyproject.toml | 35 + backend/pytest_output.txt | 3854 +++++++++++++++++ backend/tests/__init__.py | 1 + backend/tests/conftest.py | 179 + backend/tests/test_api.py | 130 + backend/tests/test_configgen.py | 77 + broker-control/Dockerfile | 39 + broker-control/control.py | 250 ++ compose.yaml | 151 + frontend/Dockerfile | 16 + frontend/index.html | 12 + frontend/nginx.conf | 20 + frontend/package.json | 24 + frontend/src/App.tsx | 77 + frontend/src/api.ts | 116 + frontend/src/auth.tsx | 52 + frontend/src/main.tsx | 23 + frontend/src/pages/BrokerConfig.tsx | 129 + frontend/src/pages/Clients.tsx | 125 + frontend/src/pages/Groups.tsx | 84 + frontend/src/pages/Login.tsx | 50 + frontend/src/pages/Roles.tsx | 201 + frontend/src/styles.css | 172 + frontend/src/vite-env.d.ts | 11 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 13 + mosquitto/bootstrap/mosquitto.conf | 45 + mosquitto/templates/.gitkeep | 8 + specification.md | 120 +- 54 files changed, 8480 insertions(+), 41 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/app/__init__.py create mode 100644 backend/app/audit.py create mode 100644 backend/app/config.py create mode 100644 backend/app/configgen.py create mode 100644 backend/app/db.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/dynsec.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/broker.py create mode 100644 backend/app/routers/mqtt.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/security.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/r1_0001_initial.py create mode 100644 backend/pyproject.toml create mode 100644 backend/pytest_output.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_configgen.py create mode 100644 broker-control/Dockerfile create mode 100644 broker-control/control.py create mode 100644 compose.yaml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/auth.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/BrokerConfig.tsx create mode 100644 frontend/src/pages/Clients.tsx create mode 100644 frontend/src/pages/Groups.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Roles.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 mosquitto/bootstrap/mosquitto.conf create mode 100644 mosquitto/templates/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8e4d55 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b38f24b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fa7966e --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac27f0b --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fef55e1 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..7c0fb55 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,4 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +# URL is composed at runtime from environment (see env.py); not stored here. \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..64abf21 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" \ No newline at end of file diff --git a/backend/app/audit.py b/backend/app/audit.py new file mode 100644 index 0000000..295c5ba --- /dev/null +++ b/backend/app/audit.py @@ -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). \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..5e78309 --- /dev/null +++ b/backend/app/config.py @@ -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() \ No newline at end of file diff --git a/backend/app/configgen.py b/backend/app/configgen.py new file mode 100644 index 0000000..62d7ef2 --- /dev/null +++ b/backend/app/configgen.py @@ -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 ` 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) \ No newline at end of file diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..00911b5 --- /dev/null +++ b/backend/app/db.py @@ -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() \ No newline at end of file diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..aedd265 --- /dev/null +++ b/backend/app/deps.py @@ -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)] \ No newline at end of file diff --git a/backend/app/dynsec.py b/backend/app/dynsec.py new file mode 100644 index 0000000..7d39450 --- /dev/null +++ b/backend/app/dynsec.py @@ -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/ + 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() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0615a69 --- /dev/null +++ b/backend/app/main.py @@ -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) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..807a984 --- /dev/null +++ b/backend/app/models.py @@ -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)) \ No newline at end of file diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..d36e74d --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""API routers package.""" \ No newline at end of file diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..b5b3708 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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"} \ No newline at end of file diff --git a/backend/app/routers/broker.py b/backend/app/routers/broker.py new file mode 100644 index 0000000..fb4a331 --- /dev/null +++ b/backend/app/routers/broker.py @@ -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 \ No newline at end of file diff --git a/backend/app/routers/mqtt.py b/backend/app/routers/mqtt.py new file mode 100644 index 0000000..3a19385 --- /dev/null +++ b/backend/app/routers/mqtt.py @@ -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 \ No newline at end of file diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..da4f59b --- /dev/null +++ b/backend/app/routers/users.py @@ -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 \ No newline at end of file diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..bcb03c8 --- /dev/null +++ b/backend/app/schemas.py @@ -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] \ No newline at end of file diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..b0ba7bc --- /dev/null +++ b/backend/app/security.py @@ -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) \ No newline at end of file diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..ef414f6 --- /dev/null +++ b/backend/migrations/env.py @@ -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() \ No newline at end of file diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..13b2942 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} \ No newline at end of file diff --git a/backend/migrations/versions/r1_0001_initial.py b/backend/migrations/versions/r1_0001_initial.py new file mode 100644 index 0000000..42dab45 --- /dev/null +++ b/backend/migrations/versions/r1_0001_initial.py @@ -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") \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..ccc16b1 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/backend/pytest_output.txt b/backend/pytest_output.txt new file mode 100644 index 0000000..a4088d7 --- /dev/null +++ b/backend/pytest_output.txt @@ -0,0 +1,3854 @@ +EEEEEEEEEEE..... [100%] +=================================== ERRORS ==================================== +_____________________ ERROR at setup of test_health_live ______________________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEB75DCD0> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +____________ ERROR at setup of test_login_sets_cookie_and_session _____________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEB75F490> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +_____________ ERROR at setup of test_login_wrong_password_audited _____________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEB73E450> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +_____________ ERROR at setup of test_unauthenticated_api_rejected _____________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBA88160> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +____________ ERROR at setup of test_auditor_cannot_create_clients _____________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBA8A8B0> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +_________ ERROR at setup of test_create_client_returns_password_once __________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDDDE50> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +_____________ ERROR at setup of test_disable_enable_delete_client _____________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDDFE50> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +__________________ ERROR at setup of test_role_acl_lifecycle __________________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDC8F50> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +___________________ ERROR at setup of test_group_lifecycle ____________________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBB48C80> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +___________ ERROR at setup of test_listener_management_and_preview ____________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBDCE6D0> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +________________ ERROR at setup of test_audit_events_recorded _________________ + +self = +dialect = +context = +statement = +parameters = [('SUPER_ADMIN',)] + + def _exec_single_context( + self, + dialect: Dialect, + context: ExecutionContext, + statement: Union[str, Compiled], + parameters: Optional[_AnyMultiExecuteParams], + ) -> CursorResult[Any]: + """continue the _execute_context() method for a single DBAPI + cursor.execute() or cursor.executemany() call. + + """ + if dialect.bind_typing is BindTyping.SETINPUTSIZES: + generic_setinputsizes = context._prepare_set_input_sizes() + + if generic_setinputsizes: + try: + dialect.do_set_input_sizes( + context.cursor, generic_setinputsizes, context + ) + except BaseException as e: + self._handle_dbapi_exception( + e, str(statement), parameters, None, context + ) + + cursor, str_statement, parameters = ( + context.cursor, + context.statement, + context.parameters, + ) + + effective_parameters: Optional[_AnyExecuteParams] + + if not context.executemany: + effective_parameters = parameters[0] + else: + effective_parameters = parameters + + if self._has_events or self.engine._has_events: + for fn in self.dispatch.before_cursor_execute: + str_statement, effective_parameters = fn( + self, + cursor, + str_statement, + effective_parameters, + context, + context.executemany, + ) + + if self._echo: + self._log_info(str_statement) + + stats = context._get_cache_stats() + + if not self.engine.hide_parameters: + self._log_info( + "[%s] %r", + stats, + sql_util._repr_params( + effective_parameters, + batches=10, + ismulti=context.executemany, + ), + ) + else: + self._log_info( + "[%s] [SQL parameters hidden due to hide_parameters=True]", + stats, + ) + + evt_handled: bool = False + try: + if context.execute_style is ExecuteStyle.EXECUTEMANY: + effective_parameters = cast( + "_CoreMultiExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_executemany: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: + self.dialect.do_executemany( + cursor, + str_statement, + effective_parameters, + context, + ) + elif not effective_parameters and context.no_parameters: + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute_no_params: + if fn(cursor, str_statement, context): + evt_handled = True + break + if not evt_handled: + self.dialect.do_execute_no_params( + cursor, str_statement, context + ) + else: + effective_parameters = cast( + "_CoreSingleExecuteParams", effective_parameters + ) + if self.dialect._has_events: + for fn in self.dialect.dispatch.do_execute: + if fn( + cursor, + str_statement, + effective_parameters, + context, + ): + evt_handled = True + break + if not evt_handled: +> self.dialect.do_execute( + cursor, str_statement, effective_parameters, context + ) + +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlite3.OperationalError: no such table: users + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError + +The above exception was the direct cause of the following exception: + +db_session = +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x0000019AEBBC2DD0> + + @pytest.fixture() + def client(db_session, monkeypatch): + """TestClient wired to the fixture DB; dynsec calls are faked out.""" + # Point the app's session factory at the test engine. + monkeypatch.setattr(db_module, "_SessionFactory", None) + monkeypatch.setattr( + db_module, "_engine", db_session.get_bind() + ) + + # 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: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests\conftest.py:132: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.venv\Lib\site-packages\starlette\testclient.py:698: in __enter__ + portal.call(self.wait_startup) +.venv\Lib\site-packages\anyio\from_thread.py:340: in call + return cast(T_Retval, self.start_task_soon(func, *args).result()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:450: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:733: in wait_startup + await receive() +.venv\Lib\site-packages\starlette\testclient.py:724: in receive + self.task.result() +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:443: in result + return self.__get_result() + ^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\concurrent\futures\_base.py:395: in __get_result + raise self._exception +.venv\Lib\site-packages\anyio\from_thread.py:265: in _call_func + retval = await retval_or_awaitable + ^^^^^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\starlette\testclient.py:714: in lifespan + await self.app(scope, self.stream_receive.receive, self.stream_send.send) +.venv\Lib\site-packages\fastapi\applications.py:1163: in __call__ + await super().__call__(scope, receive, send) +.venv\Lib\site-packages\starlette\applications.py:96: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\errors.py:151: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\base.py:103: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\cors.py:80: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\middleware\exceptions.py:49: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\fastapi\middleware\asyncexitstack.py:18: in __call__ + await self.app(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:670: in __call__ + await self.middleware_stack(scope, receive, send) +.venv\Lib\site-packages\fastapi\routing.py:2726: in app + await self.lifespan(scope, receive, send) +.venv\Lib\site-packages\starlette\routing.py:648: in lifespan + async with self.lifespan_context(app) as maybe_state: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +.venv\Lib\site-packages\fastapi\routing.py:240: in merged_lifespan + async with original_context(app) as maybe_original_state: + ^^^^^^^^^^^^^^^^^^^^^ +..\..\..\..\AppData\Local\Python\pythoncore-3.14-64\Lib\contextlib.py:214: in __aenter__ + return await anext(self.gen) + ^^^^^^^^^^^^^^^^^^^^^ +app\main.py:65: in lifespan + _bootstrap_admin() +app\main.py:34: in _bootstrap_admin + has_admin = session.scalar( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2421: in scalar + return self._execute_internal( +.venv\Lib\site-packages\sqlalchemy\orm\session.py:2271: in _execute_internal + result: Result[Any] = compile_state_cls.orm_execute_statement( +.venv\Lib\site-packages\sqlalchemy\orm\context.py:306: in orm_execute_statement + result = conn.execute( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1421: in execute + return meth( +.venv\Lib\site-packages\sqlalchemy\sql\elements.py:526: in _execute_on_connection + return connection._execute_clauseelement( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1643: in _execute_clauseelement + ret = self._execute_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1848: in _execute_context + return self._exec_single_context( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1988: in _exec_single_context + self._handle_dbapi_exception( +.venv\Lib\site-packages\sqlalchemy\engine\base.py:2365: in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e +.venv\Lib\site-packages\sqlalchemy\engine\base.py:1969: in _exec_single_context + self.dialect.do_execute( +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +cursor = +statement = 'SELECT users.id \nFROM users \nWHERE users.role = ?' +parameters = ('SUPER_ADMIN',) +context = + + def do_execute(self, cursor, statement, parameters, context=None): +> cursor.execute(statement, parameters) +E sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: users +E [SQL: SELECT users.id +E FROM users +E WHERE users.role = ?] +E [parameters: ('SUPER_ADMIN',)] +E (Background on this error at: https://sqlalche.me/e/20/e3q8) + +.venv\Lib\site-packages\sqlalchemy\engine\default.py:952: OperationalError +============================== warnings summary =============================== +.venv\Lib\site-packages\fastapi\testclient.py:1 + C:\Users\jonas\Documents\Git\mqtt-broker-stack\backend\.venv\Lib\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +.venv\Lib\site-packages\starlette\testclient.py:53 + C:\Users\jonas\Documents\Git\mqtt-broker-stack\backend\.venv\Lib\site-packages\starlette\testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead. + _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]] + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +=========================== short test summary info =========================== +ERROR tests/test_api.py::test_health_live - sqlalchemy.exc.OperationalError: ... +ERROR tests/test_api.py::test_login_sets_cookie_and_session - sqlalchemy.exc.... +ERROR tests/test_api.py::test_login_wrong_password_audited - sqlalchemy.exc.O... +ERROR tests/test_api.py::test_unauthenticated_api_rejected - sqlalchemy.exc.O... +ERROR tests/test_api.py::test_auditor_cannot_create_clients - sqlalchemy.exc.... +ERROR tests/test_api.py::test_create_client_returns_password_once - sqlalchem... +ERROR tests/test_api.py::test_disable_enable_delete_client - sqlalchemy.exc.O... +ERROR tests/test_api.py::test_role_acl_lifecycle - sqlalchemy.exc.Operational... +ERROR tests/test_api.py::test_group_lifecycle - sqlalchemy.exc.OperationalErr... +ERROR tests/test_api.py::test_listener_management_and_preview - sqlalchemy.ex... +ERROR tests/test_api.py::test_audit_events_recorded - sqlalchemy.exc.Operatio... +5 passed, 2 warnings, 11 errors in 11.71s + diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..d25a4be --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..db8d5de --- /dev/null +++ b/backend/tests/conftest.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..04ac22c --- /dev/null +++ b/backend/tests/test_api.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_configgen.py b/backend/tests/test_configgen.py new file mode 100644 index 0000000..7c7af8c --- /dev/null +++ b/backend/tests/test_configgen.py @@ -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 \ No newline at end of file diff --git a/broker-control/Dockerfile b/broker-control/Dockerfile new file mode 100644 index 0000000..3ed6811 --- /dev/null +++ b/broker-control/Dockerfile @@ -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 --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"] \ No newline at end of file diff --git a/broker-control/control.py b/broker-control/control.py new file mode 100644 index 0000000..0cd6a63 --- /dev/null +++ b/broker-control/control.py @@ -0,0 +1,250 @@ +"""broker-control: minimal internal control API for Mosquitto. + +Scope (spec section 29): + POST /deploy { "content": "" } -> 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 --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() \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..393f108 --- /dev/null +++ b/compose.yaml @@ -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: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0c23263 --- /dev/null +++ b/frontend/Dockerfile @@ -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 \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c7173ce --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + MQTT Management + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f385d82 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..866891b --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5bbeb05 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} + +function Protected({ children }: { children: React.ReactNode }) { + const { session, loading } = useAuth(); + if (loading) return

Loading…

; + if (!session) return ; + return {children}; +} + +export function App() { + return ( + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..c3e6cb7 --- /dev/null +++ b/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }), + put: (path: string, body?: unknown) => + request(path, { method: "PUT", body: body === undefined ? undefined : JSON.stringify(body) }), + patch: (path: string, body?: unknown) => + request(path, { method: "PATCH", body: body === undefined ? undefined : JSON.stringify(body) }), + delete: (path: string) => request(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; +} \ No newline at end of file diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx new file mode 100644 index 0000000..cdd07b2 --- /dev/null +++ b/frontend/src/auth.tsx @@ -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; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api + .get("/auth/session") + .then(setSession) + .catch(() => setSession(null)) + .finally(() => setLoading(false)); + }, []); + + const login = useCallback(async (username: string, password: string) => { + setSession(await api.post("/auth/login", { username, password })); + }, []); + + const logout = useCallback(async () => { + await api.post("/auth/logout"); + setSession(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); + return ctx; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..09a1335 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + + + , +); \ No newline at end of file diff --git a/frontend/src/pages/BrokerConfig.tsx b/frontend/src/pages/BrokerConfig.tsx new file mode 100644 index 0000000..94032a9 --- /dev/null +++ b/frontend/src/pages/BrokerConfig.tsx @@ -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("/broker/listeners"), + }); + + const preview = useQuery({ + queryKey: ["config-preview"], + queryFn: () => api.get("/broker/config/preview"), + }); + + const revisions = useQuery({ + queryKey: ["revisions"], + queryFn: () => api.get("/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 ( +
+

Broker configuration

+

+ The configuration is generated from the platform model, validated with + mosquitto --test-config, deployed atomically, and every + deployment creates a revision. +

+ +

Listeners

+ {listeners.isLoading ? ( +

Loading…

+ ) : ( + + + + + + + + + + + + + {(listeners.data ?? []).map((l) => ( + + + + + + + + + ))} + +
NamePortBindProtocolAnonymousEnabled
{l.name}{l.port}{l.bind_address ?? "0.0.0.0"}{l.protocol}{l.allow_anonymous ? "yes" : "no"}{l.enabled ? "yes" : "no"}
+ )} + +

Generated configuration

+ {preview.data?.warnings.map((w) => ( +
+ {w} +
+ ))} +
{preview.data?.config_text ?? "Generating…"}
+ + + {deploy.data && ( +
+ Deployed revision {deploy.data.revision}. Broker health:{" "} + {deploy.data.broker_healthy ? "OK" : "unknown — check logs"} +
+ )} + {deploy.error && ( +
{(deploy.error as Error).message}
+ )} + +

Revisions

+ + + + + + + + + + + + {(revisions.data ?? []).map((r) => ( + + + + + + + + ))} + +
#DeployedByCommentCreated
{r.revision_number}{r.deployed ? new Date(r.deployed_at!).toLocaleString() : "—"}{r.created_by ?? "—"}{r.comment ?? "—"}{new Date(r.created_at).toLocaleString()}
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx new file mode 100644 index 0000000..f914ffc --- /dev/null +++ b/frontend/src/pages/Clients.tsx @@ -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(null); + const [error, setError] = useState(null); + + const clients = useQuery({ + queryKey: ["clients"], + queryFn: () => api.get("/mqtt/clients"), + }); + + const create = useMutation({ + mutationFn: (username: string) => + api.post("/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 ( +
+

Clients

+

+ MQTT identities managed by the broker's dynamic security plugin. Passwords + are shown exactly once on creation or reset. +

+ + {oncePassword && ( +
+ Password for {oncePassword.username} — copy it now, it is shown + only once: +
{oncePassword.password}
+
+ )} + {error &&
{error}
} + +
+ + setNewUsername(e.target.value)} + placeholder="e.g. ps042" + /> +

+ +

+ + {clients.isLoading ? ( +

Loading clients…

+ ) : ( + + + + + + + + + + + + + {(clients.data ?? []).map((c) => ( + + + + + + + + + ))} + +
UsernameClient IDEnabledGroupsRolesActions
{c.username}{c.clientid ?? "—"}{c.enabled ? "yes" : "no"}{c.groups.map((g) => g.groupname).join(", ") || "—"}{c.roles.map((r) => r.rolename).join(", ") || "—"} + {c.enabled ? ( + + ) : ( + + )}{" "} + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Groups.tsx b/frontend/src/pages/Groups.tsx new file mode 100644 index 0000000..83949c5 --- /dev/null +++ b/frontend/src/pages/Groups.tsx @@ -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(null); + + const groups = useQuery({ + queryKey: ["groups"], + queryFn: () => api.get("/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 ( +
+

Groups

+

Assign roles to collections of clients (dynamic security groups).

+ {error &&
{error}
} + +
+ + setName(e.target.value)} + placeholder="e.g. sensors" + /> +

+ +

+ + + + + + + + + + + {(groups.data ?? []).map((g) => ( + + + + + + ))} + +
GroupRoles
{g.groupname}{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..0496eb8 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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(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 ( +
void submit(e)}> +

MQTT Management

+ {error &&
{error}
} + + setUsername(e.target.value)} + autoComplete="username" + autoFocus + /> + + setPassword(e.target.value)} + autoComplete="current-password" + /> +

+ +

+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx new file mode 100644 index 0000000..14a7167 --- /dev/null +++ b/frontend/src/pages/Roles.tsx @@ -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(null); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + + const roles = useQuery({ + queryKey: ["roles"], + queryFn: () => api.get("/mqtt/roles"), + }); + + const detail = useQuery({ + queryKey: ["role", selected], + queryFn: () => api.get(`/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) => { + 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 ( +
+

Roles & ACLs

+

+ ACL types map to dynamic security: publishClientSend (client publishes), + publishClientReceive (client receives), subscribe/unsubscribe in literal + and pattern variants. %u and %c substitute the + username and client id. +

+ {error &&
{error}
} + +
+ + setName(e.target.value)} + placeholder="e.g. site-a-sensors" + /> +

+ +

+ + + + + + + + + + + {(roles.data ?? []).map((r) => ( + + + + + + ))} + +
RoleACLsOpen
{r.rolename}{(r.acls ?? []).length} + +
+ + {role && ( +
+

+ Role: {role.rolename} +

+ + + + + + + + + + + + + {role.acls.map((acl, index) => ( + + + + + + + + ))} + +
TypeTopic filterEffectPriority
+ + + updateAcl(index, { topic: e.target.value })} + /> + + + + + updateAcl(index, { priority: Number(e.target.value) }) + } + /> + + +
+ {save.isPending &&

Saving…

} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..d2ebe98 --- /dev/null +++ b/frontend/src/styles.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..496195f --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +// 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 +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..27fab12 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b1cbd70 --- /dev/null +++ b/frontend/vite.config.ts @@ -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", + }, + }, +}); \ No newline at end of file diff --git a/mosquitto/bootstrap/mosquitto.conf b/mosquitto/bootstrap/mosquitto.conf new file mode 100644 index 0000000..1bf1c2a --- /dev/null +++ b/mosquitto/bootstrap/mosquitto.conf @@ -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 \ No newline at end of file diff --git a/mosquitto/templates/.gitkeep b/mosquitto/templates/.gitkeep new file mode 100644 index 0000000..c8fa8a0 --- /dev/null +++ b/mosquitto/templates/.gitkeep @@ -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. \ No newline at end of file diff --git a/specification.md b/specification.md index 69b140f..e4769a6 100644 --- a/specification.md +++ b/specification.md @@ -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: