🚧 WIP
This commit is contained in:
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import audit
|
from app import audit
|
||||||
|
from app.config import get_settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.deps import CurrentUser, require_roles
|
from app.deps import CurrentUser, require_roles
|
||||||
from app.dynsec import DynsecError, dynsec_client
|
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)]
|
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:
|
def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
|
||||||
return db.get(MqttClientMetadata, username)
|
return db.get(MqttClientMetadata, username)
|
||||||
|
|
||||||
@@ -63,7 +83,12 @@ def _client_out(db: Session, client: dict) -> MqttClientOut:
|
|||||||
|
|
||||||
@router.get("/clients", response_model=list[MqttClientOut])
|
@router.get("/clients", response_model=list[MqttClientOut])
|
||||||
def list_clients(db: DbDep, _user: BrokerAdmin):
|
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)
|
@router.post("/clients", response_model=MqttPasswordReset, status_code=201)
|
||||||
@@ -74,6 +99,7 @@ def create_client(
|
|||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
"""Create a dynsec client; the plaintext password is returned ONCE."""
|
"""Create a dynsec client; the plaintext password is returned ONCE."""
|
||||||
|
_guard_mgmt_identity(body.username)
|
||||||
password = body.password or generate_password()
|
password = body.password or generate_password()
|
||||||
try:
|
try:
|
||||||
dynsec_client.create_client(
|
dynsec_client.create_client(
|
||||||
@@ -109,6 +135,10 @@ def create_client(
|
|||||||
|
|
||||||
@router.get("/clients/{username}", response_model=MqttClientOut)
|
@router.get("/clients/{username}", response_model=MqttClientOut)
|
||||||
def get_client(username: str, db: DbDep, _user: BrokerAdmin):
|
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:
|
try:
|
||||||
client = dynsec_client.get_client(username)
|
client = dynsec_client.get_client(username)
|
||||||
except DynsecError as exc:
|
except DynsecError as exc:
|
||||||
@@ -123,6 +153,7 @@ def delete_client(
|
|||||||
db: DbDep,
|
db: DbDep,
|
||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
try:
|
try:
|
||||||
dynsec_client.delete_client(username)
|
dynsec_client.delete_client(username)
|
||||||
except DynsecError as exc:
|
except DynsecError as exc:
|
||||||
@@ -144,6 +175,7 @@ def delete_client(
|
|||||||
def disable_client(
|
def disable_client(
|
||||||
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
):
|
):
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
dynsec_client.disable_client(username)
|
dynsec_client.disable_client(username)
|
||||||
audit.record(
|
audit.record(
|
||||||
db, "CLIENT_DISABLED", request=request, object_type="mqtt_client", object_id=username
|
db, "CLIENT_DISABLED", request=request, object_type="mqtt_client", object_id=username
|
||||||
@@ -155,6 +187,7 @@ def disable_client(
|
|||||||
def enable_client(
|
def enable_client(
|
||||||
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
):
|
):
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
dynsec_client.enable_client(username)
|
dynsec_client.enable_client(username)
|
||||||
audit.record(
|
audit.record(
|
||||||
db, "CLIENT_ENABLED", request=request, object_type="mqtt_client", object_id=username
|
db, "CLIENT_ENABLED", request=request, object_type="mqtt_client", object_id=username
|
||||||
@@ -170,6 +203,7 @@ def reset_password(
|
|||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
"""Generate a new random password; returned once (spec section 11)."""
|
"""Generate a new random password; returned once (spec section 11)."""
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
password = generate_password()
|
password = generate_password()
|
||||||
try:
|
try:
|
||||||
dynsec_client.set_client_password(username, password)
|
dynsec_client.set_client_password(username, password)
|
||||||
@@ -194,6 +228,7 @@ def assign_client_role(
|
|||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
"""Add one role to an existing client (incremental, spec section 11)."""
|
"""Add one role to an existing client (incremental, spec section 11)."""
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
try:
|
try:
|
||||||
dynsec_client.add_client_role(username, body.rolename, body.priority)
|
dynsec_client.add_client_role(username, body.rolename, body.priority)
|
||||||
except DynsecError as exc:
|
except DynsecError as exc:
|
||||||
@@ -219,6 +254,7 @@ def unassign_client_role(
|
|||||||
db: DbDep,
|
db: DbDep,
|
||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
try:
|
try:
|
||||||
dynsec_client.remove_client_role(username, rolename)
|
dynsec_client.remove_client_role(username, rolename)
|
||||||
except DynsecError as exc:
|
except DynsecError as exc:
|
||||||
@@ -290,6 +326,7 @@ def add_group_member(
|
|||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
"""Add one client to a group (spec section 12)."""
|
"""Add one client to a group (spec section 12)."""
|
||||||
|
_guard_mgmt_identity(body.username)
|
||||||
try:
|
try:
|
||||||
dynsec_client.add_group_client(groupname, body.username, body.priority)
|
dynsec_client.add_group_client(groupname, body.username, body.priority)
|
||||||
except DynsecError as exc:
|
except DynsecError as exc:
|
||||||
@@ -315,6 +352,7 @@ def remove_group_member(
|
|||||||
db: DbDep,
|
db: DbDep,
|
||||||
_user: BrokerAdmin,
|
_user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
|
_guard_mgmt_identity(username)
|
||||||
try:
|
try:
|
||||||
dynsec_client.remove_group_client(groupname, username)
|
dynsec_client.remove_group_client(groupname, username)
|
||||||
except DynsecError as exc:
|
except DynsecError as exc:
|
||||||
|
|||||||
@@ -63,6 +63,45 @@ def test_disable_enable_delete_client(admin_client):
|
|||||||
assert response.status_code == 204
|
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):
|
def test_role_acl_lifecycle(admin_client):
|
||||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user