Files
mqtt-broker-stack/backend/app/routers/mqtt.py
T
2026-09-17 06:23:29 +02:00

458 lines
13 KiB
Python

"""MQTT identity management via Dynamic Security (spec sections 11-14).
Source-of-truth rules (spec section 40): clients/groups/roles live in the
broker; this API proxies to dynsec and keeps supplementary metadata
(owner/purpose/tags) in PostgreSQL.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app import audit
from app.db import get_db
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,
RoleIn,
RoleUpdate,
)
from app.security import generate_password
router = APIRouter(prefix="/api/v1/mqtt", tags=["mqtt-security"])
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
DbDep = Annotated[Session, Depends(get_db)]
def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
return db.get(MqttClientMetadata, username)
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=not client.get("disabled", False),
groups=client.get("groups", []),
roles=client.get("roles", []),
owner=meta.owner if meta else None,
purpose=meta.purpose if meta else None,
tags=meta.tags if meta else [],
last_known_connection=meta.last_known_connection if meta else None,
)
# --- Clients ----------------------------------------------------------------
@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()]
@router.post("/clients", response_model=MqttPasswordReset, status_code=201)
def create_client(
body: MqttClientCreate,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Create a dynsec client; the plaintext password is returned ONCE."""
password = body.password or generate_password()
try:
dynsec_client.create_client(
username=body.username,
password=password,
clientid=body.clientid,
text_name=body.text_name,
text_description=body.text_description,
groups=[g.model_dump() for g in body.groups],
roles=[r.model_dump() for r in body.roles],
)
except DynsecError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.add(
MqttClientMetadata(
username=body.username,
owner=body.owner,
purpose=body.purpose,
tags=body.tags,
)
)
audit.record(
db,
"CLIENT_CREATED",
request=request,
object_type="mqtt_client",
object_id=body.username,
new_value={"username": body.username, "groups": len(body.groups)},
)
return MqttPasswordReset(username=body.username, password=password)
@router.get("/clients/{username}", response_model=MqttClientOut)
def get_client(username: str, db: DbDep, _user: BrokerAdmin):
try:
client = dynsec_client.get_client(username)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return _client_out(db, client)
@router.delete("/clients/{username}", status_code=status.HTTP_204_NO_CONTENT)
def delete_client(
username: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
try:
dynsec_client.delete_client(username)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
meta = _metadata(db, username)
if meta is not None:
db.delete(meta)
audit.record(
db,
"CLIENT_DELETED",
request=request,
object_type="mqtt_client",
object_id=username,
)
return None
@router.post("/clients/{username}/disable", status_code=status.HTTP_204_NO_CONTENT)
def disable_client(
username: str, request: Request, db: DbDep, _user: BrokerAdmin
):
dynsec_client.disable_client(username)
audit.record(
db, "CLIENT_DISABLED", request=request, object_type="mqtt_client", object_id=username
)
return None
@router.post("/clients/{username}/enable", status_code=status.HTTP_204_NO_CONTENT)
def enable_client(
username: str, request: Request, db: DbDep, _user: BrokerAdmin
):
dynsec_client.enable_client(username)
audit.record(
db, "CLIENT_ENABLED", request=request, object_type="mqtt_client", object_id=username
)
return None
@router.post("/clients/{username}/password:reset", response_model=MqttPasswordReset)
def reset_password(
username: str,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Generate a new random password; returned once (spec section 11)."""
password = generate_password()
try:
dynsec_client.set_client_password(username, password)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_PASSWORD_RESET",
request=request,
object_type="mqtt_client",
object_id=username,
)
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 ------------------------------------------------------------------
@router.get("/groups")
def list_groups(_user: BrokerAdmin):
return dynsec_client.list_groups()
@router.post("/groups", status_code=status.HTTP_201_CREATED)
def create_group(
body: GroupIn, request: Request, db: DbDep, _user: BrokerAdmin
):
try:
dynsec_client.create_group(
groupname=body.groupname,
roles=[r.model_dump() for r in body.roles],
)
except DynsecError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
audit.record(
db,
"GROUP_CREATED",
request=request,
object_type="mqtt_group",
object_id=body.groupname,
)
return {"groupname": body.groupname}
@router.delete("/groups/{groupname}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
groupname: str, request: Request, db: DbDep, _user: BrokerAdmin
):
try:
dynsec_client.delete_group(groupname)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"GROUP_DELETED",
request=request,
object_type="mqtt_group",
object_id=groupname,
)
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()
@router.get("/roles/{rolename}")
def get_role(rolename: str, _user: BrokerAdmin):
"""Full role detail including ACLs — used by the ACL editor."""
try:
return dynsec_client.get_role(rolename)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
@router.post("/roles", status_code=status.HTTP_201_CREATED)
def create_role(
body: RoleIn, request: Request, db: DbDep, _user: BrokerAdmin
):
"""Create a role with its full ACL set (spec sections 13-14)."""
try:
dynsec_client.create_role(
rolename=body.rolename,
acls=[a.model_dump() for a in body.acls],
)
except DynsecError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
audit.record(
db,
"ROLE_CREATED",
request=request,
object_type="mqtt_role",
object_id=body.rolename,
new_value={"acl_count": len(body.acls)},
)
return {"rolename": body.rolename}
@router.put("/roles/{rolename}")
def update_role(
rolename: str,
body: RoleUpdate,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Replace the role's ACL list — the ACL editor's save action."""
try:
dynsec_client.set_role_acls(rolename, [a.model_dump() for a in body.acls])
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"ACL_CHANGED",
request=request,
object_type="mqtt_role",
object_id=rolename,
new_value={"acl_count": len(body.acls)},
)
return {"rolename": rolename}
@router.delete("/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(
rolename: str, request: Request, db: DbDep, _user: BrokerAdmin
):
try:
dynsec_client.delete_role(rolename)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"ROLE_DELETED",
request=request,
object_type="mqtt_role",
object_id=rolename,
)
return None