🚧 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:
|
||||
|
||||
+85
-36
@@ -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
|
||||
# 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(payload)
|
||||
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 == []
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-5
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
+54
-3
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
credentials: "include",
|
||||
@@ -22,7 +51,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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: <T>(path: string) => request<T>(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[];
|
||||
}
|
||||
|
||||
|
||||
+173
-11
@@ -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) => (
|
||||
<span
|
||||
key={item.name}
|
||||
className="mono"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
border: "1px solid #cbd2d9",
|
||||
borderRadius: "10px",
|
||||
padding: "0.05rem 0.4rem",
|
||||
margin: "0.1rem",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
{item.name}{" "}
|
||||
<button
|
||||
className="danger"
|
||||
style={{ padding: "0 0.3rem", marginLeft: "0.15rem" }}
|
||||
title={`Remove ${item.name}`}
|
||||
onClick={() => onRemove(item.name)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -13,30 +62,72 @@ export function ClientsPage() {
|
||||
queryFn: () => api.get<MqttClient[]>("/mqtt/clients"),
|
||||
});
|
||||
|
||||
// Roles and groups are needed for the assignment dropdowns.
|
||||
const roles = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: () => api.get<Role[]>("/mqtt/roles"),
|
||||
});
|
||||
const groups = useQuery({
|
||||
queryKey: ["groups"],
|
||||
queryFn: () => api.get<Group[]>("/mqtt/groups"),
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (username: string) =>
|
||||
api.post<PasswordReset>("/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 (
|
||||
<div>
|
||||
<h2>Clients</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{oncePassword && (
|
||||
@@ -86,19 +181,85 @@ export function ClientsPage() {
|
||||
<th>Username</th>
|
||||
<th>Client ID</th>
|
||||
<th>Enabled</th>
|
||||
<th>Groups</th>
|
||||
<th>Roles</th>
|
||||
<th>Groups</th>
|
||||
<th>Assign</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(clients.data ?? []).map((c) => (
|
||||
{(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 (
|
||||
<tr key={c.username}>
|
||||
<td className="mono">{c.username}</td>
|
||||
<td className="mono">{c.clientid ?? "—"}</td>
|
||||
<td>{c.enabled ? "yes" : "no"}</td>
|
||||
<td>{c.groups.map((g) => g.groupname).join(", ") || "—"}</td>
|
||||
<td>{c.roles.map((r) => r.rolename).join(", ") || "—"}</td>
|
||||
<td>
|
||||
<AssignmentChips
|
||||
items={assignedRoles}
|
||||
onRemove={(rolename) =>
|
||||
unassignRole.mutate({ username: c.username, rolename })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<AssignmentChips
|
||||
items={assignedGroups}
|
||||
onRemove={(groupname) =>
|
||||
leaveGroup.mutate({ groupname, username: c.username })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{freeRoles.length > 0 ? (
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value)
|
||||
assignRole.mutate({
|
||||
username: c.username,
|
||||
rolename: e.target.value,
|
||||
});
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ role…</option>
|
||||
{freeRoles.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}{" "}
|
||||
{freeGroups.length > 0 ? (
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value)
|
||||
joinGroup.mutate({
|
||||
groupname: e.target.value,
|
||||
username: c.username,
|
||||
});
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ group…</option>
|
||||
{freeGroups.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{c.enabled ? (
|
||||
<button onClick={() => disable.mutate(c.username)}>Disable</button>
|
||||
@@ -116,7 +277,8 @@ export function ClientsPage() {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
@@ -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<Group[]>("/mqtt/groups"),
|
||||
});
|
||||
|
||||
// For the per-group assignment dropdowns.
|
||||
const roles = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: () => api.get<Role[]>("/mqtt/roles"),
|
||||
});
|
||||
const clients = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: () => api.get<MqttClient[]>("/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 (
|
||||
<div>
|
||||
<h2>Groups</h2>
|
||||
<p>Assign roles to collections of clients (dynamic security groups).</p>
|
||||
<p>
|
||||
Assign roles to collections of clients (dynamic security groups). Each
|
||||
group manages its own roles and members.
|
||||
</p>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
|
||||
<form className="stack" onSubmit={submit}>
|
||||
@@ -57,14 +122,127 @@ export function GroupsPage() {
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Roles</th>
|
||||
<th>Members</th>
|
||||
<th>Add</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(groups.data ?? []).map((g) => (
|
||||
{(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 (
|
||||
<tr key={g.groupname}>
|
||||
<td className="mono">{g.groupname}</td>
|
||||
<td>{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"}</td>
|
||||
<td>
|
||||
{groupRoles.length === 0 ? (
|
||||
"—"
|
||||
) : (
|
||||
groupRoles.map((r) => (
|
||||
<span
|
||||
key={r.name}
|
||||
className="mono"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
border: "1px solid #cbd2d9",
|
||||
borderRadius: "10px",
|
||||
padding: "0.05rem 0.4rem",
|
||||
margin: "0.1rem",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
{r.name}{" "}
|
||||
<button
|
||||
className="danger"
|
||||
style={{ padding: "0 0.3rem", marginLeft: "0.15rem" }}
|
||||
title={`Remove role ${r.name}`}
|
||||
onClick={() =>
|
||||
dropRole.mutate({ groupname: g.groupname, rolename: r.name })
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{members.length === 0 ? (
|
||||
"—"
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<span
|
||||
key={m.name}
|
||||
className="mono"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
border: "1px solid #cbd2d9",
|
||||
borderRadius: "10px",
|
||||
padding: "0.05rem 0.4rem",
|
||||
margin: "0.1rem",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
{m.name}{" "}
|
||||
<button
|
||||
className="danger"
|
||||
style={{ padding: "0 0.3rem", marginLeft: "0.15rem" }}
|
||||
title={`Remove member ${m.name}`}
|
||||
onClick={() =>
|
||||
dropMember.mutate({ groupname: g.groupname, username: m.name })
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{freeRoles.length > 0 ? (
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value)
|
||||
addRole.mutate({ groupname: g.groupname, rolename: e.target.value });
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ role…</option>
|
||||
{freeRoles.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}{" "}
|
||||
{freeMembers.length > 0 ? (
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value)
|
||||
addMember.mutate({
|
||||
groupname: g.groupname,
|
||||
username: e.target.value,
|
||||
});
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ member…</option>
|
||||
{freeMembers.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="danger"
|
||||
@@ -76,7 +254,8 @@ export function GroupsPage() {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+125
-29
@@ -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<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [banner, setBanner] = useState<string | null>(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<AclRule[]>([]);
|
||||
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<AclRule>) => {
|
||||
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<AclRule>) => {
|
||||
setDirty(true);
|
||||
setDraft((rows) => rows.map((a, i) => (i === index ? { ...a, ...patch } : a)));
|
||||
};
|
||||
|
||||
const addAcl = () => {
|
||||
if (!role) return;
|
||||
save.mutate({
|
||||
...role,
|
||||
acls: [
|
||||
...role.acls,
|
||||
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.
|
||||
</p>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
{banner && !error && <div className="banner ok">{banner}</div>}
|
||||
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<label htmlFor="role-name">New role name</label>
|
||||
@@ -104,7 +162,6 @@ export function RolesPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>ACLs</th>
|
||||
<th>Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -112,9 +169,8 @@ export function RolesPage() {
|
||||
{(roles.data ?? []).map((r) => (
|
||||
<tr key={r.rolename}>
|
||||
<td className="mono">{r.rolename}</td>
|
||||
<td>{(r.acls ?? []).length}</td>
|
||||
<td>
|
||||
<button onClick={() => setSelected(r.rolename)}>Edit ACLs</button>
|
||||
<button onClick={() => startEditing(r.rolename)}>Edit ACLs</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -126,9 +182,31 @@ export function RolesPage() {
|
||||
<h3>
|
||||
Role: <span className="mono">{role.rolename}</span>
|
||||
</h3>
|
||||
<button onClick={addAcl} disabled={save.isPending}>
|
||||
+ Add ACL rule
|
||||
</button>
|
||||
<button onClick={addDraftAcl}>+ Add ACL rule</button>{" "}
|
||||
<button
|
||||
className="primary"
|
||||
onClick={saveAcls}
|
||||
disabled={!canSave || save.isPending}
|
||||
title={
|
||||
validationIssues.length > 0
|
||||
? "Fix the highlighted rows before saving"
|
||||
: dirty
|
||||
? "Save all changes"
|
||||
: "Nothing to save"
|
||||
}
|
||||
>
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</button>{" "}
|
||||
{dirty && !save.isPending && (
|
||||
<button onClick={discard}>Discard changes</button>
|
||||
)}
|
||||
{validationIssues.length > 0 && (
|
||||
<div className="banner warn">
|
||||
{validationIssues.length} rule
|
||||
{validationIssues.length > 1 ? "s" : ""} need attention before saving.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table style={{ marginTop: "1rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -140,13 +218,17 @@ export function RolesPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{role.acls.map((acl, index) => (
|
||||
{draft.map((acl, index) => {
|
||||
const issue = aclIssue(acl);
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<select
|
||||
value={acl.acltype}
|
||||
onChange={(e) =>
|
||||
updateAcl(index, { acltype: e.target.value as (typeof ACL_TYPES)[number] })
|
||||
updateDraftAcl(index, {
|
||||
acltype: e.target.value as (typeof ACL_TYPES)[number],
|
||||
})
|
||||
}
|
||||
>
|
||||
{ACL_TYPES.map((t) => (
|
||||
@@ -161,13 +243,21 @@ export function RolesPage() {
|
||||
className="mono"
|
||||
value={acl.topic}
|
||||
placeholder="site/a/%u/telemetry/#"
|
||||
onChange={(e) => updateAcl(index, { topic: e.target.value })}
|
||||
onChange={(e) => updateDraftAcl(index, { topic: e.target.value })}
|
||||
style={issue ? { borderColor: "#e5534b" } : undefined}
|
||||
/>
|
||||
{issue && (
|
||||
<div style={{ color: "#e5534b", fontSize: "0.8rem" }}>
|
||||
{issue}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={acl.allow ? "allow" : "deny"}
|
||||
onChange={(e) => updateAcl(index, { allow: e.target.value === "allow" })}
|
||||
onChange={(e) =>
|
||||
updateDraftAcl(index, { allow: e.target.value === "allow" })
|
||||
}
|
||||
>
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
@@ -180,20 +270,26 @@ export function RolesPage() {
|
||||
max={100000}
|
||||
value={acl.priority}
|
||||
onChange={(e) =>
|
||||
updateAcl(index, { priority: Number(e.target.value) })
|
||||
updateDraftAcl(index, { priority: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button className="danger" onClick={() => removeAcl(index)}>
|
||||
<button className="danger" onClick={() => removeDraftAcl(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{save.isPending && <p>Saving…</p>}
|
||||
{draft.length === 0 && (
|
||||
<p style={{ color: "#52606d" }}>
|
||||
No ACL rules yet. Use “+ Add ACL rule” — rows are editable
|
||||
immediately and are only sent to the broker on Save.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user