"""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.config import get_settings 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 _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) 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): 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) def create_client( body: MqttClientCreate, request: Request, db: DbDep, _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( 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): # 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: 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, ): _guard_mgmt_identity(username) 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 ): _guard_mgmt_identity(username) 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 ): _guard_mgmt_identity(username) 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).""" _guard_mgmt_identity(username) 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).""" _guard_mgmt_identity(username) 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, ): _guard_mgmt_identity(username) 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).""" _guard_mgmt_identity(body.username) 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, ): _guard_mgmt_identity(username) 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