Files
mqtt-broker-stack/backend/app/models.py
T

235 lines
10 KiB
Python

"""ORM models — Release 1 subset (spec sections 39, 9, 11, 13, 14, 26).
Dynamic Security objects (clients/groups/roles) are NOT stored here; the
broker owns them (spec section 40). PostgreSQL holds management users,
RBAC, broker config model + revisions, and MQTT client metadata.
"""
from __future__ import annotations
import enum
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> datetime:
"""Timezone-aware UTC now — Postgres timestamptz requires aware values."""
return datetime.now(UTC)
# --------------------------------------------------------------------------
# Management identities + RBAC (spec sections 8, 9)
# --------------------------------------------------------------------------
class ManagementRole(str, enum.Enum):
"""Built-in platform roles (spec section 9). Custom roles: later release."""
SUPER_ADMIN = "super_admin"
BROKER_ADMIN = "broker_admin"
PKI_ADMIN = "pki_admin"
OPERATOR = "operator"
AUDITOR = "auditor"
class ManagementUser(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
# Argon2id hash (spec section 43); never the plaintext password.
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str | None] = mapped_column(String(200))
role: Mapped[ManagementRole] = mapped_column(
Enum(ManagementRole, name="management_role"),
default=ManagementRole.BROKER_ADMIN,
)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
modified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class ManagementSession(Base):
"""Server-side session store (spec section 8: revocable, expiring)."""
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# --------------------------------------------------------------------------
# Broker configuration model + revisions (spec sections 25, 26, 30, 31, 61)
# --------------------------------------------------------------------------
class ListenerConfig(Base):
"""One Mosquitto listener as managed through the UI (spec section 30)."""
__tablename__ = "broker_listeners"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100))
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
port: Mapped[int] = mapped_column(Integer)
bind_address: Mapped[str | None] = mapped_column(String(100))
protocol: Mapped[str] = mapped_column(String(20), default="mqtt") # mqtt|websockets
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False)
max_connections: Mapped[int] = mapped_column(Integer, default=-1)
# TLS settings (used once PKI ships in Release 2; kept now so the
# config model is stable from the start).
tls_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
tls_min_version: Mapped[str | None] = mapped_column(String(20))
require_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
use_identity_as_username: Mapped[bool] = mapped_column(Boolean, default=False)
cert_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("certificates.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
modified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class BrokerSettings(Base):
"""Key/value store for global broker options (spec section 31)."""
__tablename__ = "broker_settings"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
value: Mapped[str] = mapped_column(Text)
value_type: Mapped[str] = mapped_column(String(20), default="string")
class ConfigRevision(Base):
"""Generated mosquitto.conf revisions (spec sections 26, 61)."""
__tablename__ = "broker_config_revisions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
revision_number: Mapped[int] = mapped_column(Integer, unique=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(100))
comment: Mapped[str | None] = mapped_column(Text)
# Full generated mosquitto.conf text
config_text: Mapped[str] = mapped_column(Text)
validation_result: Mapped[dict] = mapped_column(JSON, default=dict)
deployed: Mapped[bool] = mapped_column(Boolean, default=False)
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
__table_args__ = (
UniqueConstraint("revision_number", name="uq_config_revision_number"),
)
# --------------------------------------------------------------------------
# PKI placeholders (populated in Release 2; referenced by listeners)
# --------------------------------------------------------------------------
class Certificate(Base):
"""A CA or leaf certificate in the platform PKI store (spec 16-21).
Hierarchy: a CA row has is_ca=True and parent_id=None (internal
self-signed root/intermediate, Release 2 MVP keeps exactly one level).
A leaf row points at the CA that signed it. Private keys are stored
ENCRYPTED (AES-256-GCM, see app/crypto.py) — never in plaintext.
"""
__tablename__ = "certificates"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
common_name: Mapped[str] = mapped_column(String(200))
certificate_pem: Mapped[str] = mapped_column(Text)
# Encrypted at rest (AES-256-GCM via app/crypto.py), spec section 43.
private_key_pem: Mapped[str | None] = mapped_column(Text)
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# --- Release 2 additions ---
is_ca: Mapped[bool] = mapped_column(Boolean, default=False)
# CA that signed this certificate (None for self-signed CAs).
parent_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("certificates.id", ondelete="SET NULL")
)
# server|client — client certificates are bound to MQTT identities.
cert_type: Mapped[str] = mapped_column(String(20), default="server")
# MQTT client identity this certificate authenticates (client certs).
username: Mapped[str | None] = mapped_column(String(100), index=True)
key_algorithm: Mapped[str] = mapped_column(String(20), default="ecdsa-p256")
serial_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
dns_sans: Mapped[list] = mapped_column(JSON, default=list)
ip_sans: Mapped[list] = mapped_column(JSON, default=list)
fingerprint: Mapped[str] = mapped_column(String(200), index=True)
# issued|expired|revoked — derived/stored at issue time and on expiry checks
status: Mapped[str] = mapped_column(String(20), default="issued")
# --------------------------------------------------------------------------
# MQTT client metadata (spec sections 11, 40) — dynsec owns the auth data,
# PostgreSQL stores the descriptive/ownership metadata.
# --------------------------------------------------------------------------
class MqttClientMetadata(Base):
__tablename__ = "mqtt_client_metadata"
username: Mapped[str] = mapped_column(String(100), primary_key=True)
display_name: Mapped[str | None] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(Text)
owner: Mapped[str | None] = mapped_column(String(200))
purpose: Mapped[str | None] = mapped_column(String(200))
tags: Mapped[list] = mapped_column(JSON, default=list)
last_known_connection: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
modified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
# Certificate link becomes active in Release 2 (mTLS identity binding).
certificate_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("certificates.id", ondelete="SET NULL")
)
# --------------------------------------------------------------------------
# Audit log (spec section 38) — append-only from the application's view.
# --------------------------------------------------------------------------
class AuditEvent(Base):
__tablename__ = "audit_events"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
user: Mapped[str | None] = mapped_column(String(100))
source_ip: Mapped[str | None] = mapped_column(String(64))
action: Mapped[str] = mapped_column(String(100), index=True)
object_type: Mapped[str | None] = mapped_column(String(100))
object_id: Mapped[str | None] = mapped_column(String(200))
old_value: Mapped[str | None] = mapped_column(JSON)
new_value: Mapped[str | None] = mapped_column(JSON)
result: Mapped[str] = mapped_column(String(20), default="success")
correlation_id: Mapped[str | None] = mapped_column(String(64))