From ee4800993d5aedd0c8791dc2bfa79eded0216dbf Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Thu, 17 Sep 2026 06:23:29 +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 --- backend/.dockerignore | 14 ++ backend/app/configgen.py | 4 +- backend/app/dynsec.py | 123 ++++++++++----- backend/app/routers/mqtt.py | 153 +++++++++++++++++- backend/app/schemas.py | 21 +++ backend/tests/conftest.py | 48 +++++- backend/tests/test_api.py | 62 +++++++- backend/tests/test_configgen.py | 3 +- broker-control/control.py | 17 +- compose.yaml | 19 ++- frontend/.dockerignore | 4 + frontend/src/api.ts | 57 ++++++- frontend/src/pages/Clients.tsx | 226 ++++++++++++++++++++++---- frontend/src/pages/Groups.tsx | 219 +++++++++++++++++++++++--- frontend/src/pages/Roles.tsx | 244 ++++++++++++++++++++--------- mosquitto/Dockerfile | 14 ++ mosquitto/bootstrap/mosquitto.conf | 4 +- 17 files changed, 1043 insertions(+), 189 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 frontend/.dockerignore create mode 100644 mosquitto/Dockerfile diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5ff671a --- /dev/null +++ b/backend/.dockerignore @@ -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.* \ No newline at end of file diff --git a/backend/app/configgen.py b/backend/app/configgen.py index 62d7ef2..b0fb4c6 100644 --- a/backend/app/configgen.py +++ b/backend/app/configgen.py @@ -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: diff --git a/backend/app/dynsec.py b/backend/app/dynsec.py index 7d39450..d45daee 100644 --- a/backend/app/dynsec.py +++ b/backend/app/dynsec.py @@ -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": ""}]} + Response <- $CONTROL/dynamic-security/v1/response (FIXED topic) + {"responses": [{"command": "createClient", + "correlationData": "", + "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/ - 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. diff --git a/backend/app/routers/mqtt.py b/backend/app/routers/mqtt.py index 3a19385..c4cc6ee 100644 --- a/backend/app/routers/mqtt.py +++ b/backend/app/routers/mqtt.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bcb03c8..c4fa7d8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index db8d5de..7c8e3b2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 04ac22c..0ca93a2 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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 diff --git a/backend/tests/test_configgen.py b/backend/tests/test_configgen.py index 7c7af8c..090d57e 100644 --- a/backend/tests/test_configgen.py +++ b/backend/tests/test_configgen.py @@ -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 == [] diff --git a/broker-control/control.py b/broker-control/control.py index 0cd6a63..86dd6b9 100644 --- a/broker-control/control.py +++ b/broker-control/control.py @@ -125,23 +125,24 @@ def _broker_pid_alive() -> bool: def _post_broker_health() -> bool: - """Cheap post-signal health probe: subscribe to $SYS/broker/version.""" + """Post-signal health probe: a real dynsec roundtrip as the admin user. + + Uses mosquitto_ctrl rather than a $SYS subscription, because the 2.1 + auto-generated admin role set is not guaranteed to include $SYS read + access — but it must always be able to administer dynamic security. + """ username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin") token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "") cmd = [ - "mosquitto_sub", + "mosquitto_ctrl", "-h", "127.0.0.1", "-u", username, "-P", token_pw, - "-t", - "$SYS/broker/version", - "-C", - "1", - "-W", - "3", + "dynsec", + "listClients", ] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10) diff --git a/compose.yaml b/compose.yaml index 393f108..ba16956 100644 --- a/compose.yaml +++ b/compose.yaml @@ -38,7 +38,13 @@ services: retries: 12 mosquitto: - image: eclipse-mosquitto:2.1.2-alpine + # Broker image = pinned base (eclipse-mosquitto:2.1.2-alpine, pinned + # inside mosquitto/Dockerfile) with our bootstrap config BAKED IN. + # Docker's copy-up then seeds a fresh mosquitto_config volume with + # OUR dynsec-enabled config instead of the stock anonymous one; + # existing volumes (holding deployed revisions) are never touched. + build: + context: ./mosquitto 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. @@ -55,14 +61,17 @@ services: # 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: a REAL dynamic-security roundtrip via mosquitto_ctrl — + # proves broker up + dynsec plugin loaded + admin auth works, exactly + # what the backend depends on. NOT a $SYS probe: the 2.1 auto-generated + # admin role set does not guarantee $SYS read access. + # ($$ escapes compose interpolation; resolved inside the container.) 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"] + test: ["CMD-SHELL", "mosquitto_ctrl -h 127.0.0.1 -u admin -P \"$$MOSQUITTO_DYNSEC_PASSWORD\" dynsec listClients >/dev/null 2>&1"] interval: 10s timeout: 5s retries: 12 - start_period: 10s + start_period: 15s mosquitto-control: build: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e12d08b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +.env.* \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c3e6cb7..81aa408 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,6 +12,35 @@ export class ApiError extends Error { } } +// FastAPI errors arrive as {detail: string} or {detail: ValidationIssue[]} +// (422) or {error: string}. Arrays MUST be flattened to a string before +// rendering — React 19 throws "Objects are not valid as a React child" +// on a raw array/object child. +function errorMessage(body: unknown): string | null { + if (typeof body !== "object" || body === null) return null; + const record = body as Record; + const detail = record.detail; + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + return detail + .map((issue) => { + if (typeof issue === "string") return issue; + const issueRecord = issue as Record; + const loc = Array.isArray(issueRecord.loc) + ? (issueRecord.loc as unknown[]).slice(-1).join("") + : ""; + const msg = + typeof issueRecord.msg === "string" + ? issueRecord.msg + : JSON.stringify(issue); + return loc ? `${loc}: ${msg}` : msg; + }) + .join("; "); + } + if (typeof record.error === "string") return record.error; + return null; +} + async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${BASE}${path}`, { credentials: "include", @@ -22,7 +51,7 @@ async function request(path: string, init?: RequestInit): Promise { let message = response.statusText; try { const body = await response.json(); - message = body.detail ?? body.error ?? message; + message = errorMessage(body) ?? message; } catch { /* non-JSON error body */ } @@ -43,6 +72,26 @@ export const api = { delete: (path: string) => request(path, { method: "DELETE" }), }; +// --- Assignment operations (spec sections 11-12) --------------------------- + +export const assignClientRole = (username: string, rolename: string, priority = -1) => + api.post(`/mqtt/clients/${username}/roles`, { rolename, priority }); + +export const removeClientRole = (username: string, rolename: string) => + api.delete(`/mqtt/clients/${username}/roles/${rolename}`); + +export const assignGroupMember = (groupname: string, username: string, priority = -1) => + api.post(`/mqtt/groups/${groupname}/clients`, { username, priority }); + +export const removeGroupMember = (groupname: string, username: string) => + api.delete(`/mqtt/groups/${groupname}/clients/${username}`); + +export const assignGroupRole = (groupname: string, rolename: string, priority = -1) => + api.post(`/mqtt/groups/${groupname}/roles`, { rolename, priority }); + +export const removeGroupRole = (groupname: string, rolename: string) => + api.delete(`/mqtt/groups/${groupname}/roles/${rolename}`); + // --- Types (mirrors backend schemas) --------------------------------------- export interface SessionInfo { @@ -58,10 +107,12 @@ export interface AclRule { priority: number; } +// Wire format note: dynsec uses textname/textdescription (no underscore); +// the backend getRole/listRoles responses pass these through as-is. export interface Role { rolename: string; - text_name?: string | null; - text_description?: string | null; + textname?: string | null; + textdescription?: string | null; acls: AclRule[]; } diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx index f914ffc..6f1b1c6 100644 --- a/frontend/src/pages/Clients.tsx +++ b/frontend/src/pages/Clients.tsx @@ -1,6 +1,55 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, type MqttClient, type PasswordReset } from "../api"; +import { + api, + assignClientRole, + assignGroupMember, + removeClientRole, + removeGroupMember, + type MqttClient, + type PasswordReset, + type Role, + type Group, +} from "../api"; + +/** Small inline "chip + remove" editor for one client row. */ +function AssignmentChips({ + items, + onRemove, +}: { + items: { name: string }[]; + onRemove: (name: string) => void; +}) { + if (items.length === 0) return <>—; + return ( + <> + {items.map((item) => ( + + {item.name}{" "} + + + ))} + + ); +} export function ClientsPage() { const queryClient = useQueryClient(); @@ -13,30 +62,72 @@ export function ClientsPage() { queryFn: () => api.get("/mqtt/clients"), }); + // Roles and groups are needed for the assignment dropdowns. + const roles = useQuery({ + queryKey: ["roles"], + queryFn: () => api.get("/mqtt/roles"), + }); + const groups = useQuery({ + queryKey: ["groups"], + queryFn: () => api.get("/mqtt/groups"), + }); + + const refresh = () => { + queryClient.invalidateQueries({ queryKey: ["clients"] }); + }; + const create = useMutation({ mutationFn: (username: string) => api.post("/mqtt/clients", { username }), onSuccess: (result) => { setOncePassword(result); setNewUsername(""); - queryClient.invalidateQueries({ queryKey: ["clients"] }); + refresh(); }, onError: (e: Error) => setError(e.message), }); const disable = useMutation({ mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }), + onSuccess: refresh, }); const enable = useMutation({ mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }), + onSuccess: refresh, }); const remove = useMutation({ mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const assignRole = useMutation({ + mutationFn: ({ username, rolename }: { username: string; rolename: string }) => + assignClientRole(username, rolename), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const unassignRole = useMutation({ + mutationFn: ({ username, rolename }: { username: string; rolename: string }) => + removeClientRole(username, rolename), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const joinGroup = useMutation({ + mutationFn: ({ groupname, username }: { groupname: string; username: string }) => + assignGroupMember(groupname, username), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const leaveGroup = useMutation({ + mutationFn: ({ groupname, username }: { groupname: string; username: string }) => + removeGroupMember(groupname, username), + onSuccess: refresh, onError: (e: Error) => setError(e.message), }); @@ -46,12 +137,16 @@ export function ClientsPage() { if (newUsername) create.mutate(newUsername); }; + const roleNames = (roles.data ?? []).map((r) => r.rolename); + const groupNames = (groups.data ?? []).map((g) => g.groupname); + return (

Clients

MQTT identities managed by the broker's dynamic security plugin. Passwords - are shown exactly once on creation or reset. + are shown exactly once on creation or reset. Use the selectors to assign + roles or group memberships directly.

{oncePassword && ( @@ -86,37 +181,104 @@ export function ClientsPage() { Username Client ID Enabled - Groups Roles + Groups + Assign Actions - {(clients.data ?? []).map((c) => ( - - {c.username} - {c.clientid ?? "—"} - {c.enabled ? "yes" : "no"} - {c.groups.map((g) => g.groupname).join(", ") || "—"} - {c.roles.map((r) => r.rolename).join(", ") || "—"} - - {c.enabled ? ( - - ) : ( - - )}{" "} - - - - ))} + {(clients.data ?? []).map((c) => { + const assignedRoles = c.roles.map((r) => ({ name: r.rolename })); + const assignedGroups = c.groups.map((g) => ({ name: g.groupname })); + const freeRoles = roleNames.filter( + (name) => !assignedRoles.some((a) => a.name === name), + ); + const freeGroups = groupNames.filter( + (name) => !assignedGroups.some((a) => a.name === name), + ); + return ( + + {c.username} + {c.clientid ?? "—"} + {c.enabled ? "yes" : "no"} + + + unassignRole.mutate({ username: c.username, rolename }) + } + /> + + + + leaveGroup.mutate({ groupname, username: c.username }) + } + /> + + + {freeRoles.length > 0 ? ( + + ) : null}{" "} + {freeGroups.length > 0 ? ( + + ) : null} + + + {c.enabled ? ( + + ) : ( + + )}{" "} + + + + ); + })} )} diff --git a/frontend/src/pages/Groups.tsx b/frontend/src/pages/Groups.tsx index 83949c5..65af628 100644 --- a/frontend/src/pages/Groups.tsx +++ b/frontend/src/pages/Groups.tsx @@ -1,6 +1,15 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, type Group } from "../api"; +import { + api, + assignGroupMember, + assignGroupRole, + removeGroupMember, + removeGroupRole, + type Group, + type MqttClient, + type Role, +} from "../api"; export function GroupsPage() { const queryClient = useQueryClient(); @@ -12,18 +21,61 @@ export function GroupsPage() { queryFn: () => api.get("/mqtt/groups"), }); + // For the per-group assignment dropdowns. + const roles = useQuery({ + queryKey: ["roles"], + queryFn: () => api.get("/mqtt/roles"), + }); + const clients = useQuery({ + queryKey: ["clients"], + queryFn: () => api.get("/mqtt/clients"), + }); + + const refresh = () => { + queryClient.invalidateQueries({ queryKey: ["groups"] }); + queryClient.invalidateQueries({ queryKey: ["clients"] }); + }; + const create = useMutation({ mutationFn: (groupname: string) => api.post("/mqtt/groups", { groupname }), onSuccess: () => { setName(""); - queryClient.invalidateQueries({ queryKey: ["groups"] }); + refresh(); }, onError: (e: Error) => setError(e.message), }); const remove = useMutation({ mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["groups"] }), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const addRole = useMutation({ + mutationFn: ({ groupname, rolename }: { groupname: string; rolename: string }) => + assignGroupRole(groupname, rolename), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const dropRole = useMutation({ + mutationFn: ({ groupname, rolename }: { groupname: string; rolename: string }) => + removeGroupRole(groupname, rolename), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const addMember = useMutation({ + mutationFn: ({ groupname, username }: { groupname: string; username: string }) => + assignGroupMember(groupname, username), + onSuccess: refresh, + onError: (e: Error) => setError(e.message), + }); + + const dropMember = useMutation({ + mutationFn: ({ groupname, username }: { groupname: string; username: string }) => + removeGroupMember(groupname, username), + onSuccess: refresh, onError: (e: Error) => setError(e.message), }); @@ -32,10 +84,23 @@ export function GroupsPage() { if (name) create.mutate(name); }; + const roleNames = (roles.data ?? []).map((r) => r.rolename); + const clientNames = (clients.data ?? []).map((c) => c.username); + + // Group membership lives on the client objects (c.groups), so derive + // members from the clients query rather than the group list. + const membersOf = (groupname: string) => + (clients.data ?? []) + .filter((c) => c.groups.some((g) => g.groupname === groupname)) + .map((c) => ({ name: c.username })); + return (

Groups

-

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

+

+ Assign roles to collections of clients (dynamic security groups). Each + group manages its own roles and members. +

{error &&
{error}
}
@@ -57,26 +122,140 @@ export function GroupsPage() { Group Roles + Members + Add - {(groups.data ?? []).map((g) => ( - - {g.groupname} - {(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"} - - - - - ))} + {(groups.data ?? []).map((g) => { + const groupRoles = (g.roles ?? []).map((r) => ({ name: r.rolename })); + const members = membersOf(g.groupname); + const freeRoles = roleNames.filter( + (name) => !groupRoles.some((r) => r.name === name), + ); + const freeMembers = clientNames.filter( + (name) => !members.some((m) => m.name === name), + ); + return ( + + {g.groupname} + + {groupRoles.length === 0 ? ( + "—" + ) : ( + groupRoles.map((r) => ( + + {r.name}{" "} + + + )) + )} + + + {members.length === 0 ? ( + "—" + ) : ( + members.map((m) => ( + + {m.name}{" "} + + + )) + )} + + + {freeRoles.length > 0 ? ( + + ) : null}{" "} + {freeMembers.length > 0 ? ( + + ) : null} + + + + + + ); + })}
diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index 14a7167..13a7b1b 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type Role, type AclRule } from "../api"; @@ -11,11 +11,28 @@ const ACL_TYPES = [ "unsubscribePattern", ] as const; +// Per-row client-side validation. A rule must be complete before Save is +// enabled — this is what makes the Save button's disabled state explain +// itself instead of silently blocking. +function aclIssue(acl: AclRule): string | null { + if (!acl.topic || acl.topic.trim() === "") { + return "topic filter is required"; + } + if (acl.topic.includes("#") && !acl.topic.endsWith("#")) { + return "'#' must be the last level of the filter"; + } + if (acl.topic.split("/").some((level) => level === "+#" || level === "#+")) { + return "wildcards must be the only item in a level"; + } + return null; +} + export function RolesPage() { const queryClient = useQueryClient(); const [selected, setSelected] = useState(null); const [name, setName] = useState(""); const [error, setError] = useState(null); + const [banner, setBanner] = useState(null); const roles = useQuery({ queryKey: ["roles"], @@ -28,10 +45,34 @@ export function RolesPage() { enabled: selected !== null, }); + // ---- Draft-then-save model ------------------------------------------- + // The ACL table is edited in LOCAL state; nothing hits the wire until + // Save is pressed. This fixes the original bug where "+ Add" instantly + // PUT a placeholder row with an empty topic — a 422 that left the UI + // with no row and no fields to type into. + const [draft, setDraft] = useState([]); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + // Re-seed the draft whenever the server data changes (initial load, + // refetch after save) — but never while the user is mid-edit. + if (!dirty && detail.data) { + setDraft(detail.data.acls ?? []); + } + }, [detail.data, dirty]); + + const startEditing = (rolename: string) => { + setSelected(rolename); + setDirty(false); + setBanner(null); + setError(null); + }; + const create = useMutation({ mutationFn: (rolename: string) => api.post("/mqtt/roles", { rolename, acls: [] }), onSuccess: () => { setName(""); + setBanner("Role created. Open it to add ACL rules."); queryClient.invalidateQueries({ queryKey: ["roles"] }); }, onError: (e: Error) => setError(e.message), @@ -40,8 +81,12 @@ export function RolesPage() { const save = useMutation({ mutationFn: (role: Role) => api.put(`/mqtt/roles/${role.rolename}`, role), onSuccess: () => { + setDirty(false); + setBanner("ACLs saved."); setError(null); + // Refetch: the server response re-seeds the draft via useEffect. queryClient.invalidateQueries({ queryKey: ["role", selected] }); + queryClient.invalidateQueries({ queryKey: ["roles"] }); }, onError: (e: Error) => setError(e.message), }); @@ -53,26 +98,38 @@ export function RolesPage() { 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 updateDraftAcl = (index: number, patch: Partial) => { + setDirty(true); + setDraft((rows) => rows.map((a, i) => (i === index ? { ...a, ...patch } : a))); }; - const addAcl = () => { - if (!role) return; - save.mutate({ - ...role, - acls: [ - ...role.acls, - { acltype: "publishClientSend", topic: "", allow: true, priority: 0 }, - ], - }); + const addDraftAcl = () => { + setDirty(true); + setDraft((rows) => [ + ...rows, + { acltype: "publishClientSend", topic: "", allow: true, priority: 0 }, + ]); }; - const removeAcl = (index: number) => { + const removeDraftAcl = (index: number) => { + setDirty(true); + setDraft((rows) => rows.filter((_, i) => i !== index)); + }; + + const validationIssues = draft + .map((acl, index) => ({ index, issue: aclIssue(acl) })) + .filter((entry) => entry.issue !== null); + const canSave = role !== undefined && dirty && validationIssues.length === 0; + + const saveAcls = () => { + if (!role || !canSave) return; + save.mutate({ ...role, acls: draft }); + }; + + const discard = () => { if (!role) return; - save.mutate({ ...role, acls: role.acls.filter((_, i) => i !== index) }); + setDraft(role.acls ?? []); + setDirty(false); }; return ( @@ -85,6 +142,7 @@ export function RolesPage() { username and client id.

{error &&
{error}
} + {banner && !error &&
{banner}
} @@ -104,7 +162,6 @@ export function RolesPage() { Role - ACLs Open @@ -112,9 +169,8 @@ export function RolesPage() { {(roles.data ?? []).map((r) => ( {r.rolename} - {(r.acls ?? []).length} - + ))} @@ -126,9 +182,31 @@ export function RolesPage() {

Role: {role.rolename}

- + {" "} + {" "} + {dirty && !save.isPending && ( + + )} + {validationIssues.length > 0 && ( +
+ {validationIssues.length} rule + {validationIssues.length > 1 ? "s" : ""} need attention before saving. +
+ )} + @@ -140,60 +218,78 @@ export function RolesPage() { - {role.acls.map((acl, index) => ( - - - - - - - - ))} + {draft.map((acl, index) => { + const issue = aclIssue(acl); + return ( + + + + + + + + ); + })}
- - - updateAcl(index, { topic: e.target.value })} - /> - - - - - updateAcl(index, { priority: Number(e.target.value) }) - } - /> - - -
+ + + updateDraftAcl(index, { topic: e.target.value })} + style={issue ? { borderColor: "#e5534b" } : undefined} + /> + {issue && ( +
+ {issue} +
+ )} +
+ + + + updateDraftAcl(index, { priority: Number(e.target.value) }) + } + /> + + +
- {save.isPending &&

Saving…

} + {draft.length === 0 && ( +

+ No ACL rules yet. Use “+ Add ACL rule” — rows are editable + immediately and are only sent to the broker on Save. +

+ )} )}
diff --git a/mosquitto/Dockerfile b/mosquitto/Dockerfile new file mode 100644 index 0000000..2eee519 --- /dev/null +++ b/mosquitto/Dockerfile @@ -0,0 +1,14 @@ +# Broker image: pinned upstream base + the platform bootstrap config. +# +# Why bake the config in instead of bind-mounting or entrypoint-copying: +# * A FRESH mosquitto_config volume gets seeded from the IMAGE content +# (Docker copy-up) — so first boot starts with OUR dynsec-enabled +# config, not the stock anonymous one. +# * Once the volume has content (bootstrap or deployed revisions), the +# image never touches it again — control-sidecar deploys survive +# restarts and image rebuilds. +# * Bind-mounting the active file would break the control sidecar's +# atomic os.replace() activation on deploy. +FROM eclipse-mosquitto:2.1.2-alpine + +COPY bootstrap/mosquitto.conf /mosquitto/config/mosquitto.conf \ No newline at end of file diff --git a/mosquitto/bootstrap/mosquitto.conf b/mosquitto/bootstrap/mosquitto.conf index 1bf1c2a..2ceaa7b 100644 --- a/mosquitto/bootstrap/mosquitto.conf +++ b/mosquitto/bootstrap/mosquitto.conf @@ -10,8 +10,8 @@ # # 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 +# Default auth behaviour in 2.x is already global (per_listener_settings +# is unset), so nothing is lost by omitting it here. # --- Internal management listener (backend-net only) --- # Plaintext is acceptable here because the network is isolated and the