🚧 WIP
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user