529 lines
16 KiB
Python
529 lines
16 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.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 (
|
|
ClientIdUpdate,
|
|
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.put("/clients/{username}/clientid", status_code=status.HTTP_204_NO_CONTENT)
|
|
def set_client_id(
|
|
username: str,
|
|
body: ClientIdUpdate,
|
|
request: Request,
|
|
db: DbDep,
|
|
_user: BrokerAdmin,
|
|
):
|
|
"""Bind (or clear) the expected client ID (spec section 11).
|
|
|
|
With a bound client id, connections must present exactly that id —
|
|
preventing one device's credentials from being reused by another
|
|
program. The broker kicks active connections when the id changes.
|
|
"""
|
|
_guard_mgmt_identity(username)
|
|
clientid = body.clientid.strip() or None
|
|
try:
|
|
dynsec_client.set_client_id(username, clientid)
|
|
except DynsecError as exc:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
|
audit.record(
|
|
db,
|
|
"CLIENT_ID_BOUND" if clientid else "CLIENT_ID_CLEARED",
|
|
request=request,
|
|
object_type="mqtt_client",
|
|
object_id=username,
|
|
old_value=None,
|
|
new_value={"clientid": clientid or ""},
|
|
)
|
|
return None
|
|
|
|
|
|
@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 |