Files
mqtt-broker-stack/backend/app/schemas.py
T
2026-09-20 07:55:18 +02:00

367 lines
10 KiB
Python

"""Pydantic request/response schemas shared across routers."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, StringConstraints
from app.models import ManagementRole
UsernameStr = Annotated[str, StringConstraints(pattern=r"^[a-zA-Z0-9_-]{1,100}$")]
TopicFilterStr = Annotated[str, StringConstraints(min_length=1, max_length=200)]
ACL_TYPES = Literal[
"publishClientSend",
"publishClientReceive",
"subscribeLiteral",
"subscribePattern",
"unsubscribeLiteral",
"unsubscribePattern",
]
# --- Auth ------------------------------------------------------------------
class LoginRequest(BaseModel):
username: str
password: str
class SessionInfo(BaseModel):
username: str
role: ManagementRole
must_change_password: bool
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=12)
# --- Management users -------------------------------------------------------
class ManagementUserOut(BaseModel):
id: uuid.UUID
username: str
display_name: str | None
role: ManagementRole
enabled: bool
must_change_password: bool
created_at: datetime
model_config = {"from_attributes": True}
class ManagementUserCreate(BaseModel):
username: UsernameStr
password: str = Field(min_length=12)
display_name: str | None = None
role: ManagementRole = ManagementRole.OPERATOR
class ManagementUserUpdate(BaseModel):
display_name: str | None = None
role: ManagementRole | None = None
enabled: bool | None = None
# --- MQTT clients ------------------------------------------------------------
class AclRuleIn(BaseModel):
"""One ACL rule as edited in the UI (spec section 14)."""
acltype: ACL_TYPES
topic: TopicFilterStr
allow: bool = True
priority: int = Field(default=0, ge=0, le=100000)
class RoleIn(BaseModel):
rolename: UsernameStr
text_name: str | None = None
text_description: str | None = None
acls: list[AclRuleIn] = Field(default_factory=list)
class RoleUpdate(BaseModel):
"""PUT /roles/{name} payload: rolename comes from the URL path."""
acls: list[AclRuleIn]
class GroupRoleIn(BaseModel):
rolename: str
priority: int = 1
class GroupIn(BaseModel):
groupname: UsernameStr
text_name: str | None = None
text_description: str | None = None
roles: list[GroupRoleIn] = Field(default_factory=list)
class ClientRoleIn(BaseModel):
rolename: str
priority: int = 1
class ClientGroupIn(BaseModel):
groupname: str
priority: int = 1
class ClientRoleAssign(BaseModel):
"""POST /clients/{username}/roles body — add one role incrementally."""
rolename: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class GroupRoleAssign(BaseModel):
"""POST /groups/{groupname}/roles body — add one role incrementally."""
rolename: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class GroupMemberAssign(BaseModel):
"""POST /groups/{groupname}/clients body — add one client to a group."""
username: str = Field(min_length=1)
priority: int = Field(default=-1, ge=-1, le=100000)
class MqttClientCreate(BaseModel):
username: UsernameStr
password: str | None = Field(
default=None,
description="If omitted a random password is generated and returned once.",
)
clientid: str | None = None
text_name: str | None = None
text_description: str | None = None
groups: list[ClientGroupIn] = Field(default_factory=list)
roles: list[ClientRoleIn] = Field(default_factory=list)
# PostgreSQL-side metadata (spec section 11)
owner: str | None = None
purpose: str | None = None
tags: list[str] = Field(default_factory=list)
class MqttClientOut(BaseModel):
username: str
clientid: str | None
text_name: str | None
text_description: str | None
enabled: bool
groups: list[dict[str, Any]]
roles: list[dict[str, Any]]
owner: str | None
purpose: str | None
tags: list[str]
last_known_connection: datetime | None
class ClientIdUpdate(BaseModel):
"""PUT /clients/{username}/clientid — bind or clear the expected
client ID (spec section 11: "bind an expected client ID").
An empty string clears the binding: any client id may then connect
with these credentials. A bound ID restricts connections to exactly
that client id (anti-credential-sharing).
"""
clientid: str = Field(default="", max_length=200)
class MqttPasswordReset(BaseModel):
"""Response for create/reset — plaintext shown exactly once (spec 11)."""
username: str
password: str
# --- Broker configuration ----------------------------------------------------
class BrokerSettingsIn(BaseModel):
key: str = Field(min_length=1, max_length=100)
value: str
value_type: Literal["string", "int", "bool"] = "string"
class BrokerSettingsOut(BrokerSettingsIn):
"""GET /broker/settings rows — same fields, from ORM attributes."""
model_config = {"from_attributes": True}
class ListenerIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
enabled: bool = True
port: int = Field(ge=1, le=65535)
bind_address: str | None = None
protocol: Literal["mqtt", "websockets"] = "mqtt"
allow_anonymous: bool = False
max_connections: int = -1
tls_enabled: bool = False
tls_min_version: Literal["tlsv1.2", "tlsv1.3"] | None = None
require_certificate: bool = False
use_identity_as_username: bool = False
# Bound server certificate (PKI): emitted as certfile/keyfile/cafile.
cert_id: uuid.UUID | None = None
class ListenerOut(ListenerIn):
id: uuid.UUID
model_config = {"from_attributes": True}
class ConfigPreviewOut(BaseModel):
config_text: str
warnings: list[str]
class BrokerStatusOut(BaseModel):
"""$SYS snapshot for the dashboard (spec sections 33, 55).
Every metric is None until the first $SYS publish arrives (the
broker publishes on sys_interval, 10 s in the bootstrap config);
`available` says whether ANY $SYS message has been ingested.
"""
available: bool
last_update: datetime | None
version: str | None = None
uptime: str | None = None
build_timestamp: str | None = None
clients_connected: int | None = None
clients_disconnected: int | None = None
clients_maximum: int | None = None
clients_total: int | None = None
clients_expired: int | None = None
messages_received: int | None = None
messages_sent: int | None = None
messages_stored: int | None = None
messages_dropped: int | None = None
publish_received: int | None = None
publish_sent: int | None = None
publish_dropped: int | None = None
bytes_received: int | None = None
bytes_sent: int | None = None
subscriptions_count: int | None = None
retained_count: int | None = None
load_messages_received_1min: float | None = None
load_messages_sent_1min: float | None = None
load_publish_received_1min: float | None = None
load_publish_sent_1min: float | None = None
load_bytes_received_1min: float | None = None
load_bytes_sent_1min: float | None = None
class DeployRequest(BaseModel):
comment: str | None = None
class RevisionOut(BaseModel):
id: uuid.UUID
revision_number: int
created_by: str | None
comment: str | None
deployed: bool
deployed_at: datetime | None
created_at: datetime
model_config = {"from_attributes": True}
class RevisionDetailOut(RevisionOut):
config_text: str
validation_result: dict[str, Any]
# --- PKI: certificate authorities + server certificates (spec 16-19) ---------
class CaCreate(BaseModel):
common_name: str = Field(min_length=1, max_length=200)
validity_days: int = Field(gt=0, le=36500)
key_algorithm: Literal["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"]
organization: str | None = None
organizational_unit: str | None = None
country: str | None = Field(default=None, max_length=2)
class ServerCertCreate(BaseModel):
"""Issue a server certificate signed by an existing CA (spec 19)."""
ca_id: uuid.UUID
common_name: str = Field(min_length=1, max_length=200)
validity_days: int = Field(gt=0, le=36500)
dns_sans: list[str] = Field(default_factory=list)
ip_sans: list[str] = Field(default_factory=list)
organization: str | None = None
organizational_unit: str | None = None
country: str | None = Field(default=None, max_length=2)
class CertificateOut(BaseModel):
"""Inventory view — NEVER includes the private key (spec section 21)."""
id: uuid.UUID
common_name: str
is_ca: bool
parent_id: uuid.UUID | None
cert_type: str
# MQTT client this certificate authenticates (client certs only;
# the CN equals the username — spec section 24).
username: str | None = None
key_algorithm: str
serial_number: str
dns_sans: list[str]
ip_sans: list[str]
fingerprint: str
status: str
expiry_date: datetime
created_at: datetime
model_config = {"from_attributes": True}
class ClientCertCreate(BaseModel):
"""Issue a client certificate bound to an MQTT client (spec section 20).
The certificate CN is the MQTT username — with
use_identity_as_username the broker maps CN -> dynsec identity
(spec section 24), so the dynsec client must already exist.
"""
username: str = Field(min_length=1, max_length=100)
ca_id: uuid.UUID
validity_days: int = Field(gt=0, le=36500)
class CertificateBundle(BaseModel):
"""One-time download payload (spec sections 20, 44).
Contains EVERYTHING including the plaintext private key — shown
exactly once, never retrievable afterwards from the inventory.
"""
certificate_id: uuid.UUID
username: str
certificate_pem: str
private_key_pem: str
ca_chain_pem: str
download_hint: str = (
"Store these files securely now — the private key cannot be "
"retrieved again."
)