This commit is contained in:
Jonas Bengtsson
2026-09-19 12:00:34 +02:00
parent 553d821fc8
commit adbaab50b3
2 changed files with 78 additions and 1 deletions
+39 -1
View File
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app import audit
from app.config import get_settings
from app.db import get_db
from app.deps import CurrentUser, require_roles
from app.dynsec import DynsecError, dynsec_client
@@ -36,6 +37,25 @@ BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER
DbDep = Annotated[Session, Depends(get_db)]
def _guard_mgmt_identity(username: str) -> None:
"""Refuse operations on the backend's own management identity.
The dynsec client named MOSQUITTO_DYNSEC_USERNAME is the platform's
control-channel service account — the backend connects to the broker
AS this identity (spec section 2.1: management identities are separate
from MQTT identities). Resetting its password or disabling it severs
the management channel: Mosquitto kicks every connection using the
username, and the backend then cannot reconnect with its env-var
password. It is therefore not a manageable MQTT client.
"""
if username == get_settings().mosquitto_dynsec_username:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"'{username}' is the broker management identity and cannot "
"be modified through client management",
)
def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
return db.get(MqttClientMetadata, username)
@@ -63,7 +83,12 @@ def _client_out(db: Session, client: dict) -> MqttClientOut:
@router.get("/clients", response_model=list[MqttClientOut])
def list_clients(db: DbDep, _user: BrokerAdmin):
return [_client_out(db, c) for c in dynsec_client.list_clients()]
mgmt = get_settings().mosquitto_dynsec_username
return [
_client_out(db, c)
for c in dynsec_client.list_clients()
if c.get("username") != mgmt
]
@router.post("/clients", response_model=MqttPasswordReset, status_code=201)
@@ -74,6 +99,7 @@ def create_client(
_user: BrokerAdmin,
):
"""Create a dynsec client; the plaintext password is returned ONCE."""
_guard_mgmt_identity(body.username)
password = body.password or generate_password()
try:
dynsec_client.create_client(
@@ -109,6 +135,10 @@ def create_client(
@router.get("/clients/{username}", response_model=MqttClientOut)
def get_client(username: str, db: DbDep, _user: BrokerAdmin):
# Hidden like in the listing: the management identity is not a
# manageable client, so it is reported as not present.
if username == get_settings().mosquitto_dynsec_username:
raise HTTPException(status.HTTP_404_NOT_FOUND, "client not found")
try:
client = dynsec_client.get_client(username)
except DynsecError as exc:
@@ -123,6 +153,7 @@ def delete_client(
db: DbDep,
_user: BrokerAdmin,
):
_guard_mgmt_identity(username)
try:
dynsec_client.delete_client(username)
except DynsecError as exc:
@@ -144,6 +175,7 @@ def delete_client(
def disable_client(
username: str, request: Request, db: DbDep, _user: BrokerAdmin
):
_guard_mgmt_identity(username)
dynsec_client.disable_client(username)
audit.record(
db, "CLIENT_DISABLED", request=request, object_type="mqtt_client", object_id=username
@@ -155,6 +187,7 @@ def disable_client(
def enable_client(
username: str, request: Request, db: DbDep, _user: BrokerAdmin
):
_guard_mgmt_identity(username)
dynsec_client.enable_client(username)
audit.record(
db, "CLIENT_ENABLED", request=request, object_type="mqtt_client", object_id=username
@@ -170,6 +203,7 @@ def reset_password(
_user: BrokerAdmin,
):
"""Generate a new random password; returned once (spec section 11)."""
_guard_mgmt_identity(username)
password = generate_password()
try:
dynsec_client.set_client_password(username, password)
@@ -194,6 +228,7 @@ def assign_client_role(
_user: BrokerAdmin,
):
"""Add one role to an existing client (incremental, spec section 11)."""
_guard_mgmt_identity(username)
try:
dynsec_client.add_client_role(username, body.rolename, body.priority)
except DynsecError as exc:
@@ -219,6 +254,7 @@ def unassign_client_role(
db: DbDep,
_user: BrokerAdmin,
):
_guard_mgmt_identity(username)
try:
dynsec_client.remove_client_role(username, rolename)
except DynsecError as exc:
@@ -290,6 +326,7 @@ def add_group_member(
_user: BrokerAdmin,
):
"""Add one client to a group (spec section 12)."""
_guard_mgmt_identity(body.username)
try:
dynsec_client.add_group_client(groupname, body.username, body.priority)
except DynsecError as exc:
@@ -315,6 +352,7 @@ def remove_group_member(
db: DbDep,
_user: BrokerAdmin,
):
_guard_mgmt_identity(username)
try:
dynsec_client.remove_group_client(groupname, username)
except DynsecError as exc:
+39
View File
@@ -63,6 +63,45 @@ def test_disable_enable_delete_client(admin_client):
assert response.status_code == 204
def test_management_identity_is_protected(admin_client):
"""The dynsec admin (MOSQUITTO_DYNSEC_USERNAME) is the backend's own
service account: hidden from the listing and immune to password reset,
disable and delete. A reset previously severed the management channel
(Mosquitto kicks all connections for the username) — hence the guard.
"""
# Create "admin" directly so the filter/guard has something to catch.
# (The FakeDynsec fixture is empty at test start.)
admin_client.post("/api/v1/mqtt/clients", json={"username": "admin"})
# Hidden from the listing even though dynsec knows the client.
listing = admin_client.get("/api/v1/mqtt/clients").json()
assert all(c["username"] != "admin" for c in listing)
assert admin_client.get("/api/v1/mqtt/clients/admin").status_code == 404
# Every mutation targeting it returns 400, not 2xx.
assert (
admin_client.post("/api/v1/mqtt/clients/admin/password:reset").status_code
== 400
)
assert admin_client.post("/api/v1/mqtt/clients/admin/disable").status_code == 400
assert admin_client.delete("/api/v1/mqtt/clients/admin").status_code == 400
# Creating a client that shadows the identity is refused as well.
assert (
admin_client.post("/api/v1/mqtt/clients", json={"username": "admin"}).status_code
== 400
)
def test_password_reset_still_works_for_normal_clients(admin_client):
admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
reset = admin_client.post("/api/v1/mqtt/clients/ps042/password:reset")
assert reset.status_code == 200
body = reset.json()
assert body["username"] == "ps042"
assert len(body["password"]) >= 20 # random password, shown once
def test_role_acl_lifecycle(admin_client):
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})