This commit is contained in:
Jonas Bengtsson
2026-09-17 06:23:29 +02:00
parent 8d2c54beae
commit ee4800993d
17 changed files with 1043 additions and 189 deletions
+14
View File
@@ -0,0 +1,14 @@
# Never copy local venvs, caches, or test output into the image.
.venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
pytest_output.txt
# Tests don't run inside the runtime image either.
tests/
# Environment files never belong in images.
.env
.env.*
+3 -1
View File
@@ -80,7 +80,9 @@ def generate_config(db: Session) -> GenerationResult:
lines.append("")
lines.append("# --- Global settings ---")
lines.append("per_listener_settings false")
# NOTE: per_listener_settings is deprecated in 2.1 (removed in 3.0).
# Plugins loaded via global_plugin apply to all listeners, and auth
# defaults are already global, so the option is simply omitted.
lines.append("allow_anonymous false")
global_lines = _global_section(db, warnings)
if global_lines:
+85 -36
View File
@@ -1,12 +1,23 @@
"""Dynamic Security client — the runtime control channel (spec sections 10-14).
The backend connects to Mosquitto as the seeded `admin` client and issues
commands on the $CONTROL/dynamic-security/v1 topic API. Commands are
request/response: publish a JSON command with a unique correlation topic,
then wait for the response on that topic (timeout-bounded).
commands on the $CONTROL/dynamic-security/v1 topic API.
Only the subset needed for Release 1 is implemented; the protocol shapes
come from the official dynsec documentation (mosquitto.org).
Wire protocol (verified against Mosquitto 2.1 source: src/control_common.c,
plugins/dynamic-security/control.c and clients.c):
Request -> $CONTROL/dynamic-security/v1
{"commands": [{"command": "createClient", ...,
"correlationData": "<id>"}]}
Response <- $CONTROL/dynamic-security/v1/response (FIXED topic)
{"responses": [{"command": "createClient",
"correlationData": "<id>",
"error": "..." (only on failure),
"data": {...}}]}
Responses are matched to requests by the echoed correlationData in the
PAYLOAD, not by topic. Command results live under `data` (data.client,
data.clients, ...). Client objects report `disabled` (absent == enabled).
"""
from __future__ import annotations
@@ -26,11 +37,20 @@ from app.config import get_settings
logger = logging.getLogger(__name__)
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
RESPONSE_TOPIC = "$CONTROL/dynamic-security/v1/response"
_RESPONSE_TIMEOUT_S = 10.0
class DynsecError(Exception):
"""Raised when the dynamic security API returns an error response."""
"""The dynamic security API returned an error for a command."""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
class DynsecTimeout(DynsecError):
"""No response from the broker management channel."""
class DynsecClient:
@@ -78,51 +98,60 @@ class DynsecClient:
# -- paho callbacks -------------------------------------------------------
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
if reason_code == 0:
# Subscribe once to the shared response pattern.
client.subscribe(f"{CONTROL_TOPIC}/response/#")
# FIXED response topic — all commands reply here; requests are
# matched by the correlationData echo inside the payload.
client.subscribe(RESPONSE_TOPIC, qos=1)
self._connected.set()
else:
logger.error("dynsec client connect failed: %s", reason_code)
def _on_message(self, client, userdata, msg) -> None:
if msg.topic != RESPONSE_TOPIC:
return
try:
payload = json.loads(msg.payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
logger.warning("unreadable dynsec response on %s", msg.topic)
logger.warning("unreadable dynsec response payload")
return
# Response topic: $CONTROL/dynamic-security/v1/response/<correlation>
parts = msg.topic.split("/")
correlation = parts[-1] if len(parts) >= 5 else None
# 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
View File
@@ -18,7 +18,10 @@ from app.deps import CurrentUser, require_roles
from app.dynsec import DynsecError, dynsec_client
from app.models import ManagementRole, MqttClientMetadata
from app.schemas import (
ClientRoleAssign,
GroupIn,
GroupMemberAssign,
GroupRoleAssign,
MqttClientCreate,
MqttClientOut,
MqttPasswordReset,
@@ -39,12 +42,13 @@ def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
def _client_out(db: Session, client: dict) -> MqttClientOut:
meta = _metadata(db, client.get("username", ""))
# dynsec reports `disabled` (absent == enabled); the API exposes `enabled`.
return MqttClientOut(
username=client.get("username", ""),
clientid=client.get("clientid") or None,
text_name=client.get("textname"),
text_description=client.get("textdescription"),
enabled=client.get("enabled", False),
enabled=not client.get("disabled", False),
groups=client.get("groups", []),
roles=client.get("roles", []),
owner=meta.owner if meta else None,
@@ -181,6 +185,55 @@ def reset_password(
return MqttPasswordReset(username=username, password=password)
@router.post("/clients/{username}/roles", status_code=status.HTTP_204_NO_CONTENT)
def assign_client_role(
username: str,
body: ClientRoleAssign,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Add one role to an existing client (incremental, spec section 11)."""
try:
dynsec_client.add_client_role(username, body.rolename, body.priority)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_ROLE_ADDED",
request=request,
object_type="mqtt_client",
object_id=username,
new_value={"rolename": body.rolename, "priority": body.priority},
)
return None
@router.delete(
"/clients/{username}/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT
)
def unassign_client_role(
username: str,
rolename: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.remove_client_role(username, rolename)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_ROLE_REMOVED",
request=request,
object_type="mqtt_client",
object_id=username,
old_value={"rolename": rolename},
)
return None
# --- Groups ------------------------------------------------------------------
@@ -228,6 +281,104 @@ def delete_group(
return None
@router.post("/groups/{groupname}/clients", status_code=status.HTTP_204_NO_CONTENT)
def add_group_member(
groupname: str,
body: GroupMemberAssign,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Add one client to a group (spec section 12)."""
try:
dynsec_client.add_group_client(groupname, body.username, body.priority)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_MEMBER_ADDED",
request=request,
object_type="mqtt_group",
object_id=groupname,
new_value={"username": body.username, "priority": body.priority},
)
return None
@router.delete(
"/groups/{groupname}/clients/{username}", status_code=status.HTTP_204_NO_CONTENT
)
def remove_group_member(
groupname: str,
username: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.remove_group_client(groupname, username)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_MEMBER_REMOVED",
request=request,
object_type="mqtt_group",
object_id=groupname,
old_value={"username": username},
)
return None
@router.post("/groups/{groupname}/roles", status_code=status.HTTP_204_NO_CONTENT)
def assign_group_role(
groupname: str,
body: GroupRoleAssign,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Add one role to a group (spec section 12)."""
try:
dynsec_client.add_group_role(groupname, body.rolename, body.priority)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_ROLE_ADDED",
request=request,
object_type="mqtt_group",
object_id=groupname,
new_value={"rolename": body.rolename, "priority": body.priority},
)
return None
@router.delete(
"/groups/{groupname}/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT
)
def unassign_group_role(
groupname: str,
rolename: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.remove_group_role(groupname, rolename)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_ROLE_REMOVED",
request=request,
object_type="mqtt_group",
object_id=groupname,
old_value={"rolename": rolename},
)
return None
@router.get("/roles")
def list_roles(_user: BrokerAdmin):
return dynsec_client.list_roles()
+21
View File
@@ -117,6 +117,27 @@ class ClientGroupIn(BaseModel):
priority: int = 1
class ClientRoleAssign(BaseModel):
"""POST /clients/{username}/roles body — add one role incrementally."""
rolename: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class GroupRoleAssign(BaseModel):
"""POST /groups/{groupname}/roles body — add one role incrementally."""
rolename: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class GroupMemberAssign(BaseModel):
"""POST /groups/{groupname}/clients body — add one client to a group."""
username: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class MqttClientCreate(BaseModel):
username: UsernameStr
password: str | None = Field(
+44 -4
View File
@@ -87,16 +87,19 @@ def client(db_session, monkeypatch):
# Fake dynsec so no broker is needed for API tests.
class FakeDynsec:
"""Mirrors the corrected wire contract: _command returns the `data`
object; client objects use `disabled` (absent == enabled)."""
def __init__(self):
self.clients: dict[str, dict] = {}
self.roles: dict[str, dict] = {}
self.groups: dict[str, dict] = {}
def _command(self, command, **kwargs):
return {"success": True}
return {}
def create_client(self, username, password, **kwargs):
self.clients[username] = {"username": username, "enabled": True, **kwargs}
self.clients[username] = {"username": username, "disabled": False, **kwargs}
def list_clients(self):
return list(self.clients.values())
@@ -105,10 +108,10 @@ def client(db_session, monkeypatch):
self.clients.pop(username, None)
def disable_client(self, username):
self.clients[username]["enabled"] = False
self.clients[username]["disabled"] = True
def enable_client(self, username):
self.clients[username]["enabled"] = True
self.clients[username]["disabled"] = False
def set_client_password(self, username, password):
pass
@@ -140,6 +143,43 @@ def client(db_session, monkeypatch):
def get_role(self, rolename):
return self.roles[rolename]
def add_client_role(self, username, rolename, priority=-1):
client = self.clients.setdefault(username, {"username": username, "disabled": False})
client["roles"] = [
*client.get("roles", []),
{"rolename": rolename, "priority": priority},
]
def remove_client_role(self, username, rolename):
client = self.clients.get(username, {})
client["roles"] = [
r for r in client.get("roles", []) if r["rolename"] != rolename
]
def add_group_client(self, groupname, username, priority=1):
self.clients.setdefault(username, {}).setdefault("groups", []).append(
{"groupname": groupname, "priority": priority}
)
def remove_group_client(self, groupname, username):
client = self.clients.get(username, {})
client["groups"] = [
g for g in client.get("groups", []) if g["groupname"] != groupname
]
def add_group_role(self, groupname, rolename, priority=-1):
group = self.groups.setdefault(groupname, {"groupname": groupname, "roles": []})
group["roles"] = [
*group.get("roles", []),
{"rolename": rolename, "priority": priority},
]
def remove_group_role(self, groupname, rolename):
group = self.groups.get(groupname, {})
group["roles"] = [
r for r in group.get("roles", []) if r["rolename"] != rolename
]
def connect(self):
pass
+61 -1
View File
@@ -99,6 +99,66 @@ def test_group_lifecycle(admin_client):
assert admin_client.delete("/api/v1/mqtt/groups/sensors").status_code == 204
def test_client_role_assignment(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "assign-test"})
response = admin_client.post(
"/api/v1/mqtt/clients/assign-test/roles",
json={"rolename": "telemetry", "priority": 5},
)
assert response.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/assign-test").json()
roles = {r["rolename"] for r in client["roles"]}
assert "telemetry" in roles
assert (
admin_client.delete("/api/v1/mqtt/clients/assign-test/roles/telemetry").status_code
== 204
)
client = admin_client.get("/api/v1/mqtt/clients/assign-test").json()
assert all(r["rolename"] != "telemetry" for r in client["roles"])
def test_group_member_assignment(admin_client):
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "sensors"})
admin_client.post("/api/v1/mqtt/clients", json={"username": "member-test"})
response = admin_client.post(
"/api/v1/mqtt/groups/sensors/clients",
json={"username": "member-test", "priority": 1},
)
assert response.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/member-test").json()
groups = {g["groupname"] for g in client["groups"]}
assert "sensors" in groups
assert (
admin_client.delete("/api/v1/mqtt/groups/sensors/clients/member-test").status_code
== 204
)
client = admin_client.get("/api/v1/mqtt/clients/member-test").json()
assert all(g["groupname"] != "sensors" for g in client["groups"])
def test_group_role_assignment(admin_client):
admin_client.post("/api/v1/mqtt/groups", json={"groupname": "sensors"})
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
response = admin_client.post(
"/api/v1/mqtt/groups/sensors/roles",
json={"rolename": "telemetry", "priority": 2},
)
assert response.status_code == 204
assert (
admin_client.delete("/api/v1/mqtt/groups/sensors/roles/telemetry").status_code
== 204
)
def test_listener_management_and_preview(admin_client):
response = admin_client.post(
"/api/v1/broker/listeners",
@@ -114,7 +174,7 @@ def test_listener_management_and_preview(admin_client):
# Generated config must not use options deprecated in 2.1 (spec 3.1).
assert "password_file" not in config_text
assert "acl_file" not in config_text
assert "per_listener_settings false" in config_text
assert "per_listener_settings" not in config_text
delete = admin_client.delete(f"/api/v1/broker/listeners/{listener_id}")
assert delete.status_code == 204
+2 -1
View File
@@ -30,9 +30,10 @@ def test_generate_with_one_listener(session):
result = generate_config(session)
assert "listener 1883" in result.config_text
assert "per_listener_settings false" in result.config_text
assert "global_plugin /usr/lib/mosquitto_dynamic_security.so" in result.config_text
assert "allow_anonymous false" in result.config_text
# Must NOT use options deprecated in 2.1 (spec 3.1).
assert "per_listener_settings" not in result.config_text
assert result.warnings == []
+9 -8
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.*
+54 -3
View File
@@ -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
View File
@@ -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>
)}
+186 -7
View File
@@ -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
View File
@@ -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>
+14
View File
@@ -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
+2 -2
View File
@@ -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