🚧 WIP
This commit is contained in:
@@ -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.*
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user