🚧 WIP
This commit is contained in:
+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.
|
||||
|
||||
Reference in New Issue
Block a user