This commit is contained in:
Jonas Bengtsson
2026-09-16 06:08:37 +02:00
parent cce6a4960e
commit 8d2c54beae
54 changed files with 8480 additions and 41 deletions
+1
View File
@@ -0,0 +1 @@
"""API routers package."""
+107
View File
@@ -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"}
+281
View File
@@ -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
+307
View File
@@ -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
+113
View File
@@ -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