235 lines
10 KiB
Python
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)) |