🚧 WIP
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API routers package."""
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Management authentication: login/logout, session cookies (spec section 8)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import audit
|
||||
from app.config import get_settings
|
||||
from app.db import get_db
|
||||
from app.deps import SESSION_COOKIE, CurrentUser
|
||||
from app.models import ManagementSession, ManagementUser
|
||||
from app.schemas import LoginRequest, PasswordChangeRequest, SessionInfo
|
||||
from app.security import (
|
||||
generate_session_id,
|
||||
hash_password,
|
||||
session_expiry,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/login", response_model=SessionInfo)
|
||||
def login(body: LoginRequest, request: Request, response: Response, db=Depends(get_db)):
|
||||
user = db.scalar(
|
||||
select(ManagementUser).where(ManagementUser.username == body.username)
|
||||
)
|
||||
ok = user is not None and user.enabled and verify_password(
|
||||
user.password_hash, body.password
|
||||
)
|
||||
if not ok:
|
||||
audit.record(
|
||||
db,
|
||||
"LOGIN_FAILED",
|
||||
request=request,
|
||||
user=body.username,
|
||||
result="failure",
|
||||
)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||
|
||||
session = ManagementSession(
|
||||
id=generate_session_id(),
|
||||
user_id=user.id,
|
||||
expires_at=session_expiry(settings.session_ttl_seconds),
|
||||
)
|
||||
db.add(session)
|
||||
audit.record(db, "LOGIN", request=request, user=user.username)
|
||||
db.flush()
|
||||
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
str(session.id),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=settings.is_production,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
return SessionInfo(
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response, user: CurrentUser, db=Depends(get_db)):
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token:
|
||||
mgmt_session = db.get(ManagementSession, token)
|
||||
if mgmt_session is not None:
|
||||
mgmt_session.revoked = True
|
||||
audit.record(db, "LOGOUT", request=request, user=user.username)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return {"status": "logged_out"}
|
||||
|
||||
|
||||
@router.get("/session", response_model=SessionInfo)
|
||||
def whoami(user: CurrentUser) -> SessionInfo:
|
||||
return SessionInfo(
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
def change_password(
|
||||
body: PasswordChangeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: CurrentUser,
|
||||
db=Depends(get_db),
|
||||
):
|
||||
if not verify_password(user.password_hash, body.current_password):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "current password incorrect")
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.must_change_password = False
|
||||
# Revoke all other sessions as a precaution after credential change.
|
||||
for mgmt_session in db.scalars(
|
||||
select(ManagementSession).where(ManagementSession.user_id == user.id)
|
||||
):
|
||||
mgmt_session.revoked = True
|
||||
audit.record(db, "PASSWORD_CHANGED", request=request, user=user.username)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return {"status": "password_changed"}
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Broker configuration management (spec sections 25-27, 30, 31, 61).
|
||||
|
||||
Release 1 flow: edit model -> generate -> validate via control sidecar ->
|
||||
deploy (activate + reload) -> revision recorded. Diffs and approval
|
||||
workflow arrive with Release 2/3 polish, but every deploy already creates
|
||||
an immutable revision (spec section 26).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.config import get_settings
|
||||
from app.configgen import generate_config
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import (
|
||||
BrokerSettings,
|
||||
ConfigRevision,
|
||||
ListenerConfig,
|
||||
ManagementRole,
|
||||
)
|
||||
from app.schemas import (
|
||||
BrokerSettingsIn,
|
||||
ConfigPreviewOut,
|
||||
DeployRequest,
|
||||
ListenerIn,
|
||||
ListenerOut,
|
||||
RevisionDetailOut,
|
||||
RevisionOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/broker", tags=["broker"])
|
||||
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
AnyReader = Annotated[
|
||||
CurrentUser,
|
||||
Depends(
|
||||
require_roles(
|
||||
ManagementRole.BROKER_ADMIN,
|
||||
ManagementRole.OPERATOR,
|
||||
ManagementRole.AUDITOR,
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _next_revision_number(db: Session) -> int:
|
||||
highest = db.scalar(
|
||||
select(ConfigRevision.revision_number).order_by(
|
||||
ConfigRevision.revision_number.desc()
|
||||
)
|
||||
)
|
||||
return (highest or 0) + 1
|
||||
|
||||
|
||||
def _call_control(
|
||||
path: str, *, method: str = "POST", json_body: dict | None = None
|
||||
) -> dict:
|
||||
settings = get_settings()
|
||||
headers = {"Authorization": f"Bearer {settings.control_api_token}"}
|
||||
try:
|
||||
with httpx.Client(base_url=settings.control_base_url, timeout=30) as client:
|
||||
response = client.request(
|
||||
method, path, headers=headers, json=json_body
|
||||
)
|
||||
except httpx.TransportError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"broker control service unreachable: {exc}",
|
||||
) from exc
|
||||
if response.status_code >= 400:
|
||||
detail = response.json().get("error", response.text)
|
||||
raise HTTPException(response.status_code, detail)
|
||||
return response.json()
|
||||
|
||||
|
||||
# --- Listeners ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/listeners", response_model=list[ListenerOut])
|
||||
def list_listeners(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||
return db.scalars(select(ListenerConfig)).all()
|
||||
|
||||
|
||||
@router.post("/listeners", response_model=ListenerOut, status_code=201)
|
||||
def create_listener(
|
||||
body: ListenerIn,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
listener = ListenerConfig(**body.model_dump())
|
||||
db.add(listener)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"LISTENER_CREATED",
|
||||
request=request,
|
||||
object_type="listener",
|
||||
object_id=str(listener.id),
|
||||
new_value={"name": body.name, "port": body.port},
|
||||
)
|
||||
return listener
|
||||
|
||||
|
||||
@router.put("/listeners/{listener_id}", response_model=ListenerOut)
|
||||
def update_listener(
|
||||
listener_id: uuid.UUID,
|
||||
body: ListenerIn,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
listener = db.get(ListenerConfig, listener_id)
|
||||
if listener is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
|
||||
for key, value in body.model_dump().items():
|
||||
setattr(listener, key, value)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"LISTENER_UPDATED",
|
||||
request=request,
|
||||
object_type="listener",
|
||||
object_id=str(listener.id),
|
||||
new_value={"name": body.name, "port": body.port},
|
||||
)
|
||||
return listener
|
||||
|
||||
|
||||
@router.delete("/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_listener(
|
||||
listener_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
listener = db.get(ListenerConfig, listener_id)
|
||||
if listener is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
|
||||
db.delete(listener)
|
||||
audit.record(
|
||||
db,
|
||||
"LISTENER_DELETED",
|
||||
request=request,
|
||||
object_type="listener",
|
||||
object_id=str(listener.id),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# --- Global settings -----------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/settings", response_model=list[BrokerSettingsIn])
|
||||
def list_settings(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||
return db.scalars(select(BrokerSettings)).all()
|
||||
|
||||
|
||||
@router.put("/settings/{key}", response_model=BrokerSettingsIn)
|
||||
def upsert_setting(
|
||||
key: str,
|
||||
body: BrokerSettingsIn,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: BrokerAdmin,
|
||||
):
|
||||
if body.key != key:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "key mismatch")
|
||||
setting = db.scalar(select(BrokerSettings).where(BrokerSettings.key == key))
|
||||
if setting is None:
|
||||
setting = BrokerSettings(key=key, value=body.value, value_type=body.value_type)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = body.value
|
||||
setting.value_type = body.value_type
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"BROKER_SETTING_CHANGED",
|
||||
request=request,
|
||||
object_type="broker_setting",
|
||||
object_id=key,
|
||||
new_value={"value": body.value},
|
||||
)
|
||||
return setting
|
||||
|
||||
|
||||
# --- Generation, validation, deployment ----------------------------------------
|
||||
|
||||
|
||||
@router.get("/config/preview", response_model=ConfigPreviewOut)
|
||||
def preview_config(
|
||||
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||
):
|
||||
"""Show the generated mosquitto.conf without deploying (spec section 32)."""
|
||||
result = generate_config(db)
|
||||
return ConfigPreviewOut(config_text=result.config_text, warnings=result.warnings)
|
||||
|
||||
|
||||
@router.post("/config/validate")
|
||||
def validate_config(
|
||||
db: Annotated[Session, Depends(get_db)], _user: BrokerAdmin
|
||||
):
|
||||
"""Generate + validate against the real broker binary (spec section 27)."""
|
||||
result = generate_config(db)
|
||||
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||
# NOTE: /deploy both validates AND activates. Release 1 keeps the
|
||||
# combined flow; Release 2 splits validate-only via a staged endpoint.
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/config/deploy")
|
||||
def deploy_config(
|
||||
body: DeployRequest,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
user: BrokerAdmin,
|
||||
):
|
||||
"""Generate -> validate -> activate + reload -> record revision."""
|
||||
result = generate_config(db)
|
||||
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||
if response.get("status") != "deployed":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
|
||||
|
||||
revision = ConfigRevision(
|
||||
revision_number=_next_revision_number(db),
|
||||
created_by=user.username,
|
||||
comment=body.comment,
|
||||
config_text=result.config_text,
|
||||
validation_result=response,
|
||||
deployed=True,
|
||||
deployed_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(revision)
|
||||
audit.record(
|
||||
db,
|
||||
"BROKER_CONFIG_DEPLOYED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="config_revision",
|
||||
object_id=str(revision.revision_number),
|
||||
new_value={"comment": body.comment},
|
||||
)
|
||||
return {
|
||||
"status": "deployed",
|
||||
"revision": revision.revision_number,
|
||||
"broker_healthy": response.get("broker_healthy"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/revisions", response_model=list[RevisionOut])
|
||||
def list_revisions(
|
||||
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||
):
|
||||
return db.scalars(
|
||||
select(ConfigRevision).order_by(ConfigRevision.revision_number.desc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.get("/revisions/{number}", response_model=RevisionDetailOut)
|
||||
def get_revision(
|
||||
number: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_user: AnyReader,
|
||||
):
|
||||
revision = db.scalar(
|
||||
select(ConfigRevision).where(ConfigRevision.revision_number == number)
|
||||
)
|
||||
if revision is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "revision not found")
|
||||
return revision
|
||||
@@ -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
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Management users admin (spec sections 7.1, 9)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import audit
|
||||
from app.deps import CurrentUser, DbSession, require_roles
|
||||
from app.models import ManagementRole, ManagementUser
|
||||
from app.schemas import (
|
||||
ManagementUserCreate,
|
||||
ManagementUserOut,
|
||||
ManagementUserUpdate,
|
||||
)
|
||||
from app.security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
AdminUser = Annotated[CurrentUser, Depends(require_roles(ManagementRole.SUPER_ADMIN))]
|
||||
|
||||
|
||||
@router.get("", response_model=list[ManagementUserOut])
|
||||
def list_users(db: DbSession, _admin: AdminUser):
|
||||
return db.scalars(select(ManagementUser)).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ManagementUserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
body: ManagementUserCreate,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
_admin: AdminUser,
|
||||
):
|
||||
if db.scalar(select(ManagementUser).where(ManagementUser.username == body.username)):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "username already exists")
|
||||
user = ManagementUser(
|
||||
username=body.username,
|
||||
password_hash=hash_password(body.password),
|
||||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
must_change_password=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"USER_CREATED",
|
||||
request=request,
|
||||
user=body.username,
|
||||
object_type="management_user",
|
||||
object_id=body.username,
|
||||
new_value={"role": body.role.value},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{username}", response_model=ManagementUserOut)
|
||||
def update_user(
|
||||
username: str,
|
||||
body: ManagementUserUpdate,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
_admin: AdminUser,
|
||||
):
|
||||
user = db.scalar(select(ManagementUser).where(ManagementUser.username == username))
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
old = {"role": user.role.value, "enabled": user.enabled}
|
||||
if body.display_name is not None:
|
||||
user.display_name = body.display_name
|
||||
if body.role is not None:
|
||||
user.role = body.role
|
||||
if body.enabled is not None:
|
||||
user.enabled = body.enabled
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
"USER_UPDATED",
|
||||
request=request,
|
||||
user=username,
|
||||
object_type="management_user",
|
||||
object_id=username,
|
||||
old_value=old,
|
||||
new_value={"role": user.role.value, "enabled": user.enabled},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{username}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
username: str,
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
_admin: AdminUser,
|
||||
):
|
||||
if username == "admin":
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "cannot delete bootstrap admin")
|
||||
user = db.scalar(select(ManagementUser).where(ManagementUser.username == username))
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
db.delete(user)
|
||||
audit.record(
|
||||
db,
|
||||
"USER_DELETED",
|
||||
request=request,
|
||||
user=username,
|
||||
object_type="management_user",
|
||||
object_id=username,
|
||||
)
|
||||
return None
|
||||
Reference in New Issue
Block a user