"""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 ( GroupIn, 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", "")) return MqttClientOut( username=client.get("username", ""), clientid=client.get("clientid") or None, text_name=client.get("textname"), text_description=client.get("textdescription"), enabled=client.get("enabled", 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) # --- 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.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