This commit is contained in:
Jonas Bengtsson
2026-09-17 06:23:29 +02:00
parent 8d2c54beae
commit ee4800993d
17 changed files with 1043 additions and 189 deletions
+14
View File
@@ -0,0 +1,14 @@
# Never copy local venvs, caches, or test output into the image.
.venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
pytest_output.txt
# Tests don't run inside the runtime image either.
tests/
# Environment files never belong in images.
.env
.env.*
+3 -1
View File
@@ -80,7 +80,9 @@ def generate_config(db: Session) -> GenerationResult:
lines.append("")
lines.append("# --- Global settings ---")
lines.append("per_listener_settings false")
# NOTE: per_listener_settings is deprecated in 2.1 (removed in 3.0).
# Plugins loaded via global_plugin apply to all listeners, and auth
# defaults are already global, so the option is simply omitted.
lines.append("allow_anonymous false")
global_lines = _global_section(db, warnings)
if global_lines:
+86 -37
View File
@@ -1,12 +1,23 @@
"""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).
commands on the $CONTROL/dynamic-security/v1 topic API.
Only the subset needed for Release 1 is implemented; the protocol shapes
come from the official dynsec documentation (mosquitto.org).
Wire protocol (verified against Mosquitto 2.1 source: src/control_common.c,
plugins/dynamic-security/control.c and clients.c):
Request -> $CONTROL/dynamic-security/v1
{"commands": [{"command": "createClient", ...,
"correlationData": "<id>"}]}
Response <- $CONTROL/dynamic-security/v1/response (FIXED topic)
{"responses": [{"command": "createClient",
"correlationData": "<id>",
"error": "..." (only on failure),
"data": {...}}]}
Responses are matched to requests by the echoed correlationData in the
PAYLOAD, not by topic. Command results live under `data` (data.client,
data.clients, ...). Client objects report `disabled` (absent == enabled).
"""
from __future__ import annotations
@@ -26,11 +37,20 @@ from app.config import get_settings
logger = logging.getLogger(__name__)
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
RESPONSE_TOPIC = "$CONTROL/dynamic-security/v1/response"
_RESPONSE_TIMEOUT_S = 10.0
class DynsecError(Exception):
"""Raised when the dynamic security API returns an error response."""
"""The dynamic security API returned an error for a command."""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
class DynsecTimeout(DynsecError):
"""No response from the broker management channel."""
class DynsecClient:
@@ -78,51 +98,60 @@ class DynsecClient:
# -- 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/#")
# FIXED response topic — all commands reply here; requests are
# matched by the correlationData echo inside the payload.
client.subscribe(RESPONSE_TOPIC, qos=1)
self._connected.set()
else:
logger.error("dynsec client connect failed: %s", reason_code)
def _on_message(self, client, userdata, msg) -> None:
if msg.topic != RESPONSE_TOPIC:
return
try:
payload = json.loads(msg.payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
logger.warning("unreadable dynsec response on %s", msg.topic)
logger.warning("unreadable dynsec response payload")
return
# Response topic: $CONTROL/dynamic-security/v1/response/<correlation>
parts = msg.topic.split("/")
correlation = parts[-1] if len(parts) >= 5 else None
if correlation and correlation in self._responses:
self._responses[correlation].put_nowait(payload)
# Envelope is {"responses": [...]}; fan entries out to waiters by
# their echoed correlationData.
for entry in payload.get("responses", []):
correlation = entry.get("correlationData")
if correlation and correlation in self._responses:
self._responses[correlation].put_nowait(entry)
# -- command plumbing -----------------------------------------------------
def _command(self, command: str, **kwargs: Any) -> dict[str, Any]:
"""Send one command; return its `data` object on success.
Raises DynsecError when the broker replies with an error, and
DynsecTimeout when no response arrives in time.
"""
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}
body = {
"commands": [
{"command": command, "correlationData": correlation, **kwargs}
]
}
try:
result = self._client.publish(
f"{CONTROL_TOPIC}", json.dumps(body).encode("utf-8"), qos=1
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)
entry = reply_queue.get(timeout=_RESPONSE_TIMEOUT_S)
except queue.Empty as exc:
raise DynsecError(f"dynsec command '{command}' timed out") from exc
raise DynsecTimeout(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
if "error" in entry:
raise DynsecError(f"{command}: {entry['error']}")
return entry.get("data") or {}
# -- Release 1 API surface (spec sections 11-14) ---------------------------
@@ -166,8 +195,9 @@ class DynsecClient:
return self._command("getClient", username=username).get("client", {})
def list_clients(self) -> list[dict[str, Any]]:
entry = self._command("listClients")
return entry.get("clients", [])
"""Full client objects (verbose=true gives groups/roles/disabled)."""
data = self._command("listClients", verbose=True)
return data.get("clients", [])
def set_client_group_memberships(
self, username: str, groups: list[dict[str, Any]]
@@ -179,6 +209,19 @@ class DynsecClient:
"""Replace a client's direct role assignments."""
self._command("modifyClient", username=username, roles=roles)
def add_client_role(self, username: str, rolename: str, priority: int = -1) -> None:
"""Incrementally add one role to a client (spec section 11).
Deliberately incremental rather than modifyClient full-list
replacement: safer against accidental clobbering from the UI.
"""
self._command(
"addClientRole", username=username, rolename=rolename, priority=priority
)
def remove_client_role(self, username: str, rolename: str) -> None:
self._command("removeClientRole", username=username, rolename=rolename)
# Groups
def create_group(self, groupname: str, roles: list[dict[str, Any]] | None = None) -> None:
params: dict[str, Any] = {"groupname": groupname}
@@ -193,12 +236,21 @@ class DynsecClient:
return self._command("getGroup", groupname=groupname).get("group", {})
def list_groups(self) -> list[dict[str, Any]]:
entry = self._command("listGroups")
return entry.get("groups", [])
data = self._command("listGroups", verbose=True)
return data.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_role(self, groupname: str, rolename: str, priority: int = -1) -> None:
"""Incrementally add one role to a group (spec section 12)."""
self._command(
"addGroupRole", groupname=groupname, rolename=rolename, priority=priority
)
def remove_group_role(self, groupname: str, rolename: str) -> None:
self._command("removeGroupRole", groupname=groupname, rolename=rolename)
def add_group_client(self, groupname: str, username: str, priority: int = 1) -> None:
self._command(
"addGroupClient", groupname=groupname, username=username, priority=priority
@@ -221,25 +273,22 @@ class DynsecClient:
return self._command("getRole", rolename=rolename).get("role", {})
def list_roles(self) -> list[dict[str, Any]]:
entry = self._command("listRoles")
return entry.get("roles", [])
data = self._command("listRoles", verbose=True)
return data.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:
def set_default_acl_access(self, acl_type: str, allow: bool) -> None:
self._command(
"setDefaultACLAccess",
defaultAclAccess=[{"acltype": acl_type, "allow": allow}],
acls=[{"acltype": acl_type, "allow": allow}],
)
def get_default_acl_access(self) -> list[dict[str, Any]]:
entry = self._command("getDefaultACLAccess")
return entry.get("defaultAclAccess", [])
return self._command("getDefaultACLAccess").get("acls", [])
# Module-level singleton used by FastAPI dependencies.
+152 -1
View File
@@ -18,7 +18,10 @@ from app.deps import CurrentUser, require_roles
from app.dynsec import DynsecError, dynsec_client
from app.models import ManagementRole, MqttClientMetadata
from app.schemas import (
ClientRoleAssign,
GroupIn,
GroupMemberAssign,
GroupRoleAssign,
MqttClientCreate,
MqttClientOut,
MqttPasswordReset,
@@ -39,12 +42,13 @@ def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
def _client_out(db: Session, client: dict) -> MqttClientOut:
meta = _metadata(db, client.get("username", ""))
# dynsec reports `disabled` (absent == enabled); the API exposes `enabled`.
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),
enabled=not client.get("disabled", False),
groups=client.get("groups", []),
roles=client.get("roles", []),
owner=meta.owner if meta else None,
@@ -181,6 +185,55 @@ def reset_password(
return MqttPasswordReset(username=username, password=password)
@router.post("/clients/{username}/roles", status_code=status.HTTP_204_NO_CONTENT)
def assign_client_role(
username: str,
body: ClientRoleAssign,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Add one role to an existing client (incremental, spec section 11)."""
try:
dynsec_client.add_client_role(username, body.rolename, body.priority)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_ROLE_ADDED",
request=request,
object_type="mqtt_client",
object_id=username,
new_value={"rolename": body.rolename, "priority": body.priority},
)
return None
@router.delete(
"/clients/{username}/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT
)
def unassign_client_role(
username: str,
rolename: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.remove_client_role(username, rolename)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_ROLE_REMOVED",
request=request,
object_type="mqtt_client",
object_id=username,
old_value={"rolename": rolename},
)
return None
# --- Groups ------------------------------------------------------------------
@@ -228,6 +281,104 @@ def delete_group(
return None
@router.post("/groups/{groupname}/clients", status_code=status.HTTP_204_NO_CONTENT)
def add_group_member(
groupname: str,
body: GroupMemberAssign,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Add one client to a group (spec section 12)."""
try:
dynsec_client.add_group_client(groupname, body.username, body.priority)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_MEMBER_ADDED",
request=request,
object_type="mqtt_group",
object_id=groupname,
new_value={"username": body.username, "priority": body.priority},
)
return None
@router.delete(
"/groups/{groupname}/clients/{username}", status_code=status.HTTP_204_NO_CONTENT
)
def remove_group_member(
groupname: str,
username: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.remove_group_client(groupname, username)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_MEMBER_REMOVED",
request=request,
object_type="mqtt_group",
object_id=groupname,
old_value={"username": username},
)
return None
@router.post("/groups/{groupname}/roles", status_code=status.HTTP_204_NO_CONTENT)
def assign_group_role(
groupname: str,
body: GroupRoleAssign,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Add one role to a group (spec section 12)."""
try:
dynsec_client.add_group_role(groupname, body.rolename, body.priority)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_ROLE_ADDED",
request=request,
object_type="mqtt_group",
object_id=groupname,
new_value={"rolename": body.rolename, "priority": body.priority},
)
return None
@router.delete(
"/groups/{groupname}/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT
)
def unassign_group_role(
groupname: str,
rolename: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.remove_group_role(groupname, rolename)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_ROLE_REMOVED",
request=request,
object_type="mqtt_group",
object_id=groupname,
old_value={"rolename": rolename},
)
return None
@router.get("/roles")
def list_roles(_user: BrokerAdmin):
return dynsec_client.list_roles()
+21
View File
@@ -117,6 +117,27 @@ class ClientGroupIn(BaseModel):
priority: int = 1
class ClientRoleAssign(BaseModel):
"""POST /clients/{username}/roles body — add one role incrementally."""
rolename: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class GroupRoleAssign(BaseModel):
"""POST /groups/{groupname}/roles body — add one role incrementally."""
rolename: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class GroupMemberAssign(BaseModel):
"""POST /groups/{groupname}/clients body — add one client to a group."""
username: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class MqttClientCreate(BaseModel):
username: UsernameStr
password: str | None = Field(
+44 -4
View File
@@ -87,16 +87,19 @@ def client(db_session, monkeypatch):
# Fake dynsec so no broker is needed for API tests.
class FakeDynsec:
"""Mirrors the corrected wire contract: _command returns the `data`
object; client objects use `disabled` (absent == enabled)."""
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}
return {}
def create_client(self, username, password, **kwargs):
self.clients[username] = {"username": username, "enabled": True, **kwargs}
self.clients[username] = {"username": username, "disabled": False, **kwargs}
def list_clients(self):
return list(self.clients.values())
@@ -105,10 +108,10 @@ def client(db_session, monkeypatch):
self.clients.pop(username, None)
def disable_client(self, username):
self.clients[username]["enabled"] = False
self.clients[username]["disabled"] = True
def enable_client(self, username):
self.clients[username]["enabled"] = True
self.clients[username]["disabled"] = False
def set_client_password(self, username, password):
pass
@@ -140,6 +143,43 @@ def client(db_session, monkeypatch):
def get_role(self, rolename):
return self.roles[rolename]
def add_client_role(self, username, rolename, priority=-1):
client = self.clients.setdefault(username, {"username": username, "disabled": False})
client["roles"] = [
*client.get("roles", []),
{"rolename": rolename, "priority": priority},
]
def remove_client_role(self, username, rolename):
client = self.clients.get(username, {})
client["roles"] = [
r for r in client.get("roles", []) if r["rolename"] != rolename
]
def add_group_client(self, groupname, username, priority=1):
self.clients.setdefault(username, {}).setdefault("groups", []).append(
{"groupname": groupname, "priority": priority}
)
def remove_group_client(self, groupname, username):
client = self.clients.get(username, {})
client["groups"] = [
g for g in client.get("groups", []) if g["groupname"] != groupname
]
def add_group_role(self, groupname, rolename, priority=-1):
group = self.groups.setdefault(groupname, {"groupname": groupname, "roles": []})
group["roles"] = [
*group.get("roles", []),
{"rolename": rolename, "priority": priority},
]
def remove_group_role(self, groupname, rolename):
group = self.groups.get(groupname, {})
group["roles"] = [
r for r in group.get("roles", []) if r["rolename"] != rolename
]
def connect(self):
pass
+61 -1
View File
@@ -99,6 +99,66 @@ def test_group_lifecycle(admin_client):
assert admin_client.delete("/api/v1/mqtt/groups/sensors").status_code == 204
def test_client_role_assignment(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "assign-test"})
response = admin_client.post(
"/api/v1/mqtt/clients/assign-test/roles",
json={"rolename": "telemetry", "priority": 5},
)
assert response.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/assign-test").json()
roles = {r["rolename"] for r in client["roles"]}
assert "telemetry" in roles
assert (
admin_client.delete("/api/v1/mqtt/clients/assign-test/roles/telemetry").status_code
== 204
)
client = admin_client.get("/api/v1/mqtt/clients/assign-test").json()
assert all(r["rolename"] != "telemetry" for r in client["roles"])
def test_group_member_assignment(admin_client):
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "sensors"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "member-test"})
response = admin_client.post(
"/api/v1/mqtt/groups/sensors/clients",
json={"username": "member-test", "priority": 1},
)
assert response.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/member-test").json()
groups = {g["groupname"] for g in client["groups"]}
assert "sensors" in groups
assert (
admin_client.delete("/api/v1/mqtt/groups/sensors/clients/member-test").status_code
== 204
)
client = admin_client.get("/api/v1/mqtt/clients/member-test").json()
assert all(g["groupname"] != "sensors" for g in client["groups"])
def test_group_role_assignment(admin_client):
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "sensors"})
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
response = admin_client.post(
"/api/v1/mqtt/groups/sensors/roles",
json={"rolename": "telemetry", "priority": 2},
)
assert response.status_code == 204
assert (
admin_client.delete("/api/v1/mqtt/groups/sensors/roles/telemetry").status_code
== 204
)
def test_listener_management_and_preview(admin_client):
response = admin_client.post(
"/api/v1/broker/listeners",
@@ -114,7 +174,7 @@ def test_listener_management_and_preview(admin_client):
# 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
assert "per_listener_settings" not in config_text
delete = admin_client.delete(f"/api/v1/broker/listeners/{listener_id}")
assert delete.status_code == 204
+2 -1
View File
@@ -30,9 +30,10 @@ def test_generate_with_one_listener(session):
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
# Must NOT use options deprecated in 2.1 (spec 3.1).
assert "per_listener_settings" not in result.config_text
assert result.warnings == []