feat: expand MQTT contract and reliability lessons

This commit is contained in:
2026-10-13 18:08:17 +03:30
parent 2d639d78eb
commit f9ab91ed60
17 changed files with 2083 additions and 91 deletions
+166
View File
@@ -0,0 +1,166 @@
# 🔐 Lesson 11B — Provision device ACLs with Mosquitto Dynamic Security
This is an optional implementation extension to [Section 11](11-security-boundaries.md). It replaces the lab's static password and ACL files with Mosquitto's Dynamic Security plugin in a dedicated lab configuration. Do not combine the two authentication systems casually: first understand which component owns each account and authorization decision.
## 🎬 Start with the provisioning story
> **❓ Question:** **A new device is installed after the broker has started. How can it receive its own credentials and topic permissions without editing files and reloading Mosquitto?**
> **💬 Answer:** A controlled provisioning operator or service authenticates with a separate management identity and asks the broker plugin to create the device client and assign least-privilege roles. The device then connects with its own credentials. The broker checks those credentials and topic permissions; the Go application still validates the delivered topic and payload.
A provisioning service (or a controlled operator job) authenticates to the broker through a separate management identity. It asks the plugin to create/enable the device client and attach least-privilege roles. The device then connects with its own credentials and can use only the topics those roles permit.
```mermaid
sequenceDiagram
participant P as Provisioning service
participant B as Mosquitto + Dynamic Security
participant D as Device
participant A as Go application
P->>B: Admin MQTT connection (TLS for remote use)
P->>B: Create device client; assign device role
B-->>P: Management result
D->>B: CONNECT with device credentials
B->>B: Authenticate client and check ACLs
D->>B: PUBLISH only to its permitted device topic
B->>A: Route topic and payload to backend subscription
A->>A: Validate topic, payload, and business rules
```
The Go backend does **not** learn the publisher username from an ordinary forwarded `PUBLISH`. The broker enforces the credential-to-topic boundary before routing. The backend still checks that the topic's device ID agrees with the payload and validates the message contract before acting.
Dynamic Security is a Mosquitto plugin, not a Go MQTT library feature. It manages broker clients, groups, roles, and ACLs through a special MQTT control API. `mosquitto_ctrl` is the command-line client for that API. Changes apply while the broker runs and are saved to the plugin's `dynamic-security.json` state file. [Official Dynamic Security guide](https://mosquitto.org/documentation/dynamic-security/)
## 🧱 1. Give the plugin its own broker configuration and state
> **❓ Question:** **What must change before the broker can accept live account-management requests?**
> **💬 Answer:** Mosquitto must load its Dynamic Security plugin and a writable, persistent plugin state file. The static password and ACL files do not automatically become plugin-managed accounts.
The current lab uses Mosquitto 2.0.22 with `password_file` and `acl_file`; it does **not** load Dynamic Security. Keep the working lab intact while learning. For this exercise create a separate Compose override/configuration and state volume, and mount `dynamic-security.json` at a persistent, broker-writable path. The broker image/version must include the plugin; verify the library path inside the pinned image rather than assuming a distribution path.
The plugin's broker configuration has this shape (replace the library path with the verified path in your image):
```conf
listener 1883
allow_anonymous false
per_listener_settings false
plugin /verified/path/mosquitto_dynamic_security.so
plugin_opt_config_file /mosquitto/data/dynamic-security.json
persistence true
persistence_location /mosquitto/data/
log_dest stdout
connection_messages true
```
For this listener, Dynamic Security supplies both username/password authentication and topic authorization. Do not also configure `password_file` or `acl_file` for the same identities as if they were one unified policy; choose a clear owner for authentication and ACL decisions. The JSON state path must be writable by the broker process and backed by persistent storage with restricted permissions.
Mosquitto 2.0 requires an initial plugin state file. Generate one with the matching `mosquitto_ctrl` utility, for example:
```sh
mosquitto_ctrl dynsec init ./lab/dynamic-security.json dynsec-admin
```
This creates the initial administrator client interactively. Protect that state file: it contains security configuration and credential hashes. Keep a restricted backup, and test restore as part of broker operations. Do not commit the generated file or put it in an image layer. Newer Mosquitto versions may initialize a missing file differently; follow the documentation for the exact pinned broker version.
Start only the isolated Dynamic Security lab and verify the plugin loaded in broker logs before creating device accounts. If startup fails, check plugin path, file mount, writable ownership, and version compatibility. Do not fall back to anonymous access to make the experiment pass.
## 🪪 2. Model identity and reusable permissions
> **❓ Question:** **How can a rule be reusable for many devices without giving them access to one another's topics?**
> **💬 Answer:** Give each device a unique authenticated username and use `%u` only as a complete topic segment in role ACL patterns. The broker substitutes the authenticated username when it evaluates the rule. A payload ID or client-selected Client ID is not a substitute for that credential binding.
Use one unique Dynamic Security client per device username, and bind its configured client ID when the device has a stable ID and the client library can consistently use it. A username is the plugin's primary client lookup key; a client ID alone is not authentication. Never share one fleet credential between devices.
Create a device role whose ACL patterns substitute `%u` with the authenticated username. In this example the username itself is the device segment, such as `device-001`:
```text
Role: device
publishClientSend lab/v1/devices/%u/telemetry allow
publishClientSend lab/v1/devices/%u/responses allow
subscribePattern lab/v1/devices/%u/commands allow
publishClientReceive lab/v1/devices/%u/commands allow
```
The separation is intentional: `publishClientSend` controls device-to-broker publications; `subscribePattern` controls what filters a device may request; `publishClientReceive` controls what broker-forwarded publications it may receive. A permitted subscription by itself is not always the complete receive policy.
Create a separate backend role. The backend may publish commands and subscribe to device telemetry/responses, but it should not use the device role. Keep the management administrator separate from both roles.
Dynamic Security's initial defaults are not deny-all: publishing to the broker and subscribing are denied by default, while receiving broker-forwarded messages is allowed by default. Set `publishClientReceive` to deny explicitly, then grant only the device's command topic through the role. Add only explicit patterns needed by each role; avoid broad grants such as application-wide `#`. Review ACL event types and wildcard behavior in the official guide before relying on a pattern.
## 🛠️ 3. Create a role and assign a device
> **❓ Question:** **What exact broker changes happen when inventory says device-001 is ready to join?**
> **💬 Answer:** The operator creates a client keyed by its unique username, binds the intended Client ID when required, and assigns the reusable device role. The device's own credential then selects that client's policy at CONNECT.
The following illustrates the `mosquitto_ctrl dynsec` command family. `mosquitto_ctrl` connects to the running broker using a separate administrator account; the device credential being created is not the admin credential.
```sh
# Create a reusable device role once.
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec createRole device
# Make receive permissions default-deny; the device role grants its command topic below.
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec setDefaultACLAccess publishClientReceive deny
# Add each role ACL once. Repeat only for ACLs your design actually needs.
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec addRoleACL device publishClientSend 'lab/v1/devices/%u/telemetry' allow 10
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec addRoleACL device publishClientSend 'lab/v1/devices/%u/responses' allow 10
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec addRoleACL device subscribePattern 'lab/v1/devices/%u/commands' allow 10
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec addRoleACL device publishClientReceive 'lab/v1/devices/%u/commands' allow 10
# Provision the per-device client, then attach the reusable role.
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec createClient device-001 -i lab-device-001
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec addClientRole device-001 device 10
```
The tool prompts for the new device password during client creation. Check the exact prompts/options with the pinned `mosquitto_ctrl` version. **Do not put real admin passwords in command arguments**: arguments may appear in process listings or shell history. The `-P "$DYNSEC_ADMIN_PASSWORD"` form above is a disposable-lab placeholder to make the connection explicit, not a production secret-delivery pattern. In a real deployment, run the controller from a restricted job/service and deliver secrets through the platform's protected credential mechanism; use TLS for remote management connections.
To remove the device's access, disable it immediately; delete it when the account is no longer needed:
```sh
mosquitto_ctrl -h 127.0.0.1 -p 18883 -u dynsec-admin -P "$DYNSEC_ADMIN_PASSWORD" \
dynsec disableClient device-001
```
Disabling/deleting a client disconnects its currently connected sessions and prevents future authentication. Record the provisioning request, target device, operator/service identity, result, and time. Make operations idempotent: a retry should detect an existing client/role rather than accidentally broadening permissions.
## 🧪 4. Prove both sides of the boundary
> **❓ Question:** **How will we know the plugin blocks cross-device access instead of merely starting successfully?**
> **💬 Answer:** Exercise both allowed and denied paths using the actual per-device credentials, then inspect reason codes, delivery, and broker logs. Startup only proves the plugin loaded; it does not prove the topic policy is correct.
Use separate device and backend credentials and test both permitted and denied operations:
1. Connect `device-001` with its configured username, password, and client ID. Publish telemetry to `lab/v1/devices/device-001/telemetry`; expect the broker to accept it.
2. Try publishing as that same client to `lab/v1/devices/device-002/telemetry`; expect the broker to deny it.
3. Try subscribing to `lab/v1/devices/device-002/commands`; expect the subscription result to show rejection.
4. Publish an allowed topic with a body claiming a different `device_id`; confirm the Go backend rejects the topic/body mismatch before changing state.
5. Disable the client while it is connected. Confirm the broker disconnects it and a later reconnect is rejected.
6. Restart the broker and verify the client/role/ACL state remains, demonstrating that the plugin state file is persisted.
Capture broker logs and client-side MQTT reason codes where available. A command's successful exit or a loaded plugin alone is not proof that the ACL is correct. Restore the isolated lab to its prior state when finished.
## 🏗️ Production design checklist
- Keep device credentials unique, revocable, rotated, and delivered through a protected provisioning channel.
- Keep Dynamic Security administrator credentials out of device firmware and ordinary message-processing code. A dedicated provisioning service/job with narrowly controlled access is usually easier to audit than giving the backend's main runtime account administrator power.
- Expose the broker management listener only where needed; require TLS and restrict network reachability. On a local-only lab, loopback access is appropriate.
- Grant per-role least privilege, separate publish from subscribe/receive, and review every wildcard. Keep default-deny behavior.
- Use a stable identity mapping. `%u` is useful only when the authenticated username is unique and provisioned to represent that device; never trust an arbitrary client-selected topic or JSON ID as identity proof.
- Persist and back up Dynamic Security state securely. Test recovery, broker upgrades, plugin compatibility, revocation, and credential rotation.
- Audit provisioning changes and alert on repeated denied access. Protect the audit stream from modification by device clients.
- Consider an external authentication/authorization service when policy must integrate with a device registry or central identity system. Dynamic Security is a broker-local role system, not a device inventory database.
**Done when:** You can explain who authenticates a device, which component decides its topic permissions, what the backend can infer from the delivered topic, and how revocation affects an already-connected client.
**Official references:** [Dynamic Security plugin](https://mosquitto.org/documentation/dynamic-security/), [Mosquitto authentication methods](https://mosquitto.org/documentation/authentication-methods/), [Dynamic Security `mosquitto_ctrl` commands](https://www.mosquitto.org/man/mosquitto_ctrl_dynsec-1.html).