"""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): __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) once keys are managed (Release 2). 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) # -------------------------------------------------------------------------- # 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))