feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
backend/app/models/__init__.py
Normal file
35
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""SQLAlchemy ORM models."""
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.device import Device, DeviceGroup, DeviceTag, DeviceGroupMembership, DeviceTagAssignment, DeviceStatus
|
||||
from app.models.alert import AlertRule, NotificationChannel, AlertRuleChannel, AlertEvent
|
||||
from app.models.firmware import FirmwareVersion, FirmwareUpgradeJob
|
||||
from app.models.config_template import ConfigTemplate, ConfigTemplateTag, TemplatePushJob
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.maintenance_window import MaintenanceWindow
|
||||
from app.models.api_key import ApiKey
|
||||
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
"User",
|
||||
"UserRole",
|
||||
"Device",
|
||||
"DeviceGroup",
|
||||
"DeviceTag",
|
||||
"DeviceGroupMembership",
|
||||
"DeviceTagAssignment",
|
||||
"DeviceStatus",
|
||||
"AlertRule",
|
||||
"NotificationChannel",
|
||||
"AlertRuleChannel",
|
||||
"AlertEvent",
|
||||
"FirmwareVersion",
|
||||
"FirmwareUpgradeJob",
|
||||
"ConfigTemplate",
|
||||
"ConfigTemplateTag",
|
||||
"TemplatePushJob",
|
||||
"AuditLog",
|
||||
"MaintenanceWindow",
|
||||
"ApiKey",
|
||||
]
|
||||
177
backend/app/models/alert.py
Normal file
177
backend/app/models/alert.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Alert system ORM models: rules, notification channels, and alert events."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
"""Configurable alert threshold rule.
|
||||
|
||||
Rules can be tenant-wide (device_id=NULL), device-specific, or group-scoped.
|
||||
When a metric breaches the threshold for duration_polls consecutive polls,
|
||||
an alert fires.
|
||||
"""
|
||||
__tablename__ = "alert_rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
device_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
group_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("device_groups.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metric: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
operator: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
threshold: Mapped[float] = mapped_column(Numeric, nullable=False)
|
||||
duration_polls: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||
severity: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AlertRule id={self.id} name={self.name!r} metric={self.metric}>"
|
||||
|
||||
|
||||
class NotificationChannel(Base):
|
||||
"""Email, webhook, or Slack notification destination."""
|
||||
__tablename__ = "notification_channels"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
channel_type: Mapped[str] = mapped_column(Text, nullable=False) # "email", "webhook", or "slack"
|
||||
# SMTP fields (email channels)
|
||||
smtp_host: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
smtp_user: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
smtp_password: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) # AES-256-GCM encrypted
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
from_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
to_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Webhook fields
|
||||
webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Slack fields
|
||||
slack_webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# OpenBao Transit ciphertext (dual-write migration)
|
||||
smtp_password_transit: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NotificationChannel id={self.id} name={self.name!r} type={self.channel_type}>"
|
||||
|
||||
|
||||
class AlertRuleChannel(Base):
|
||||
"""Many-to-many association between alert rules and notification channels."""
|
||||
__tablename__ = "alert_rule_channels"
|
||||
|
||||
rule_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("alert_rules.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
|
||||
class AlertEvent(Base):
|
||||
"""Record of an alert firing, resolving, or flapping.
|
||||
|
||||
rule_id is NULL for system-level alerts (e.g., device offline).
|
||||
"""
|
||||
__tablename__ = "alert_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
rule_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("alert_rules.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False) # "firing", "resolved", "flapping"
|
||||
severity: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metric: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
value: Mapped[float | None] = mapped_column(Numeric, nullable=True)
|
||||
threshold: Mapped[float | None] = mapped_column(Numeric, nullable=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_flapping: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
acknowledged_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
silenced_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
fired_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AlertEvent id={self.id} status={self.status} severity={self.severity}>"
|
||||
60
backend/app/models/api_key.py
Normal file
60
backend/app/models/api_key.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""API key ORM model for tenant-scoped programmatic access."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""Tracks API keys for programmatic access to the portal.
|
||||
|
||||
Keys are stored as SHA-256 hashes (never plaintext).
|
||||
Scoped permissions limit what each key can do.
|
||||
Revocation is soft-delete (sets revoked_at, row preserved for audit).
|
||||
"""
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
key_prefix: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
scopes: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'::jsonb")
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ApiKey id={self.id} name={self.name} prefix={self.key_prefix}>"
|
||||
59
backend/app/models/audit_log.py
Normal file
59
backend/app/models/audit_log.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Audit log model for centralized audit trail."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Records all auditable actions in the system (config changes, CRUD, auth events)."""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
device_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
)
|
||||
# Transit-encrypted details JSON (vault:v1:...) — set when details are encrypted
|
||||
encrypted_details: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog id={self.id} action={self.action!r} tenant_id={self.tenant_id}>"
|
||||
140
backend/app/models/certificate.py
Normal file
140
backend/app/models/certificate.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Certificate Authority and Device Certificate ORM models.
|
||||
|
||||
Supports the Internal Certificate Authority feature:
|
||||
- CertificateAuthority: one per tenant, stores encrypted CA private key + public cert
|
||||
- DeviceCertificate: per-device signed certificate with lifecycle status tracking
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, LargeBinary, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CertificateAuthority(Base):
|
||||
"""Per-tenant root Certificate Authority.
|
||||
|
||||
Each tenant has at most one CA. The CA private key is encrypted with
|
||||
AES-256-GCM before storage (using the same pattern as device credentials).
|
||||
The public cert_pem is not sensitive and can be distributed freely.
|
||||
"""
|
||||
|
||||
__tablename__ = "certificate_authorities"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
common_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
cert_pem: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
encrypted_private_key: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
serial_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
fingerprint_sha256: Mapped[str] = mapped_column(String(95), nullable=False)
|
||||
not_valid_before: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
not_valid_after: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
# OpenBao Transit ciphertext (dual-write migration)
|
||||
encrypted_private_key_transit: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<CertificateAuthority id={self.id} "
|
||||
f"cn={self.common_name!r} tenant={self.tenant_id}>"
|
||||
)
|
||||
|
||||
|
||||
class DeviceCertificate(Base):
|
||||
"""Per-device TLS certificate signed by the tenant's CA.
|
||||
|
||||
Status lifecycle:
|
||||
issued -> deploying -> deployed -> expiring -> expired
|
||||
\\-> revoked
|
||||
\\-> superseded (when rotated)
|
||||
"""
|
||||
|
||||
__tablename__ = "device_certificates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
ca_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("certificate_authorities.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
common_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
serial_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
fingerprint_sha256: Mapped[str] = mapped_column(String(95), nullable=False)
|
||||
cert_pem: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
encrypted_private_key: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
not_valid_before: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
not_valid_after: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
# OpenBao Transit ciphertext (dual-write migration)
|
||||
encrypted_private_key_transit: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default="issued"
|
||||
)
|
||||
deployed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<DeviceCertificate id={self.id} "
|
||||
f"cn={self.common_name!r} status={self.status}>"
|
||||
)
|
||||
178
backend/app/models/config_backup.py
Normal file
178
backend/app/models/config_backup.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""SQLAlchemy models for config backup tables."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ConfigBackupRun(Base):
|
||||
"""Metadata for a single config backup run.
|
||||
|
||||
The actual config content (export.rsc and backup.bin) lives in the tenant's
|
||||
bare git repository at GIT_STORE_PATH/{tenant_id}.git. This table provides
|
||||
the timeline view and per-run metadata without duplicating file content in
|
||||
PostgreSQL.
|
||||
"""
|
||||
|
||||
__tablename__ = "config_backup_runs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Git commit hash in the tenant's bare repo where this backup is stored.
|
||||
commit_sha: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Trigger type: 'scheduled' | 'manual' | 'pre-restore' | 'checkpoint' | 'config-change'
|
||||
trigger_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
# Lines added/removed vs the prior export.rsc for this device.
|
||||
# NULL for the first backup (no prior version to diff against).
|
||||
lines_added: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lines_removed: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Encryption metadata: NULL=plaintext, 1=client-side AES-GCM, 2=OpenBao Transit
|
||||
encryption_tier: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||
# 12-byte AES-GCM nonce for Tier 1 (client-side) backups; NULL for plaintext/Transit
|
||||
encryption_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ConfigBackupRun id={self.id} device_id={self.device_id} "
|
||||
f"trigger={self.trigger_type!r} sha={self.commit_sha[:8]!r}>"
|
||||
)
|
||||
|
||||
|
||||
class ConfigBackupSchedule(Base):
|
||||
"""Per-tenant default and per-device override backup schedule config.
|
||||
|
||||
A row with device_id=NULL is the tenant-level default (daily at 2am).
|
||||
A row with a specific device_id overrides the tenant default for that device.
|
||||
"""
|
||||
|
||||
__tablename__ = "config_backup_schedules"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "device_id", name="uq_backup_schedule_tenant_device"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# NULL = tenant-level default schedule; non-NULL = device-specific override.
|
||||
device_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
# Standard cron expression (5 fields). Default: daily at 2am UTC.
|
||||
cron_expression: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
default="0 2 * * *",
|
||||
server_default="0 2 * * *",
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default="TRUE",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scope = f"device={self.device_id}" if self.device_id else f"tenant={self.tenant_id}"
|
||||
return f"<ConfigBackupSchedule {scope} cron={self.cron_expression!r} enabled={self.enabled}>"
|
||||
|
||||
|
||||
class ConfigPushOperation(Base):
|
||||
"""Tracks pending two-phase config push operations for panic-revert recovery.
|
||||
|
||||
Before pushing a config, a row is inserted with status='pending_verification'.
|
||||
If the API pod restarts during the 60-second verification window, the startup
|
||||
handler checks this table and either commits (deletes the RouterOS scheduler
|
||||
job) or marks the operation as 'failed'. This prevents the panic-revert
|
||||
scheduler from firing and reverting a successful push after an API restart.
|
||||
|
||||
See Pitfall 6 in 04-RESEARCH.md for the full failure scenario.
|
||||
"""
|
||||
|
||||
__tablename__ = "config_push_operations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Git commit SHA we'd revert to if the push fails.
|
||||
pre_push_commit_sha: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# RouterOS scheduler job name created on the device for panic-revert.
|
||||
scheduler_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# 'pending_verification' | 'committed' | 'reverted' | 'failed'
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
nullable=False,
|
||||
default="pending_verification",
|
||||
server_default="pending_verification",
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ConfigPushOperation id={self.id} device_id={self.device_id} "
|
||||
f"status={self.status!r}>"
|
||||
)
|
||||
153
backend/app/models/config_template.py
Normal file
153
backend/app/models/config_template.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Config template, template tag, and template push job models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ConfigTemplate(Base):
|
||||
__tablename__ = "config_templates"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_config_templates_tenant_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
variables: Mapped[list] = mapped_column(JSON, nullable=False, default=list, server_default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant") # type: ignore[name-defined]
|
||||
tags: Mapped[list["ConfigTemplateTag"]] = relationship(
|
||||
"ConfigTemplateTag", back_populates="template", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ConfigTemplate id={self.id} name={self.name!r} tenant_id={self.tenant_id}>"
|
||||
|
||||
|
||||
class ConfigTemplateTag(Base):
|
||||
__tablename__ = "config_template_tags"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("template_id", "name", name="uq_config_template_tags_template_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("config_templates.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
template: Mapped["ConfigTemplate"] = relationship(
|
||||
"ConfigTemplate", back_populates="tags"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ConfigTemplateTag id={self.id} name={self.name!r} template_id={self.template_id}>"
|
||||
|
||||
|
||||
class TemplatePushJob(Base):
|
||||
__tablename__ = "template_push_jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("config_templates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
rollout_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=True,
|
||||
)
|
||||
rendered_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
default="pending",
|
||||
server_default="pending",
|
||||
)
|
||||
pre_push_backup_sha: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
template: Mapped["ConfigTemplate | None"] = relationship("ConfigTemplate")
|
||||
device: Mapped["Device"] = relationship("Device") # type: ignore[name-defined]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TemplatePushJob id={self.id} status={self.status!r} device_id={self.device_id}>"
|
||||
214
backend/app/models/device.py
Normal file
214
backend/app/models/device.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Device, DeviceGroup, DeviceTag, and membership models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DeviceStatus(str, Enum):
|
||||
"""Device connection status."""
|
||||
UNKNOWN = "unknown"
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "devices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "hostname", name="uq_devices_tenant_hostname"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
hostname: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
ip_address: Mapped[str] = mapped_column(String(45), nullable=False) # IPv4 or IPv6
|
||||
api_port: Mapped[int] = mapped_column(Integer, default=8728, nullable=False)
|
||||
api_ssl_port: Mapped[int] = mapped_column(Integer, default=8729, nullable=False)
|
||||
model: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
serial_number: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
firmware_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
routeros_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
routeros_major_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_cpu_load: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_memory_used_pct: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
architecture: Mapped[str | None] = mapped_column(Text, nullable=True) # CPU arch (arm, arm64, mipsbe, etc.)
|
||||
preferred_channel: Mapped[str] = mapped_column(
|
||||
Text, default="stable", server_default="stable", nullable=False
|
||||
) # Firmware release channel
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# AES-256-GCM encrypted credentials (username + password JSON)
|
||||
encrypted_credentials: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
# OpenBao Transit ciphertext (dual-write migration)
|
||||
encrypted_credentials_transit: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=DeviceStatus.UNKNOWN.value,
|
||||
nullable=False,
|
||||
)
|
||||
tls_mode: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="auto",
|
||||
server_default="auto",
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="devices") # type: ignore[name-defined]
|
||||
group_memberships: Mapped[list["DeviceGroupMembership"]] = relationship(
|
||||
"DeviceGroupMembership", back_populates="device", cascade="all, delete-orphan"
|
||||
)
|
||||
tag_assignments: Mapped[list["DeviceTagAssignment"]] = relationship(
|
||||
"DeviceTagAssignment", back_populates="device", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Device id={self.id} hostname={self.hostname!r} tenant_id={self.tenant_id}>"
|
||||
|
||||
|
||||
class DeviceGroup(Base):
|
||||
__tablename__ = "device_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_device_groups_tenant_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
preferred_channel: Mapped[str] = mapped_column(
|
||||
Text, default="stable", server_default="stable", nullable=False
|
||||
) # Firmware release channel for the group
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="device_groups") # type: ignore[name-defined]
|
||||
memberships: Mapped[list["DeviceGroupMembership"]] = relationship(
|
||||
"DeviceGroupMembership", back_populates="group", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceGroup id={self.id} name={self.name!r} tenant_id={self.tenant_id}>"
|
||||
|
||||
|
||||
class DeviceTag(Base):
|
||||
__tablename__ = "device_tags"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_device_tags_tenant_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # hex color e.g. #FF5733
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="device_tags") # type: ignore[name-defined]
|
||||
assignments: Mapped[list["DeviceTagAssignment"]] = relationship(
|
||||
"DeviceTagAssignment", back_populates="tag", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceTag id={self.id} name={self.name!r} tenant_id={self.tenant_id}>"
|
||||
|
||||
|
||||
class DeviceGroupMembership(Base):
|
||||
__tablename__ = "device_group_memberships"
|
||||
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("device_groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
device: Mapped["Device"] = relationship("Device", back_populates="group_memberships")
|
||||
group: Mapped["DeviceGroup"] = relationship("DeviceGroup", back_populates="memberships")
|
||||
|
||||
|
||||
class DeviceTagAssignment(Base):
|
||||
__tablename__ = "device_tag_assignments"
|
||||
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
tag_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("device_tags.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
device: Mapped["Device"] = relationship("Device", back_populates="tag_assignments")
|
||||
tag: Mapped["DeviceTag"] = relationship("DeviceTag", back_populates="assignments")
|
||||
102
backend/app/models/firmware.py
Normal file
102
backend/app/models/firmware.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Firmware version tracking and upgrade job ORM models."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Integer,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import ForeignKey
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FirmwareVersion(Base):
|
||||
"""Cached firmware version from MikroTik download server or poller discovery.
|
||||
|
||||
Not tenant-scoped — firmware versions are global data shared across all tenants.
|
||||
"""
|
||||
__tablename__ = "firmware_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("architecture", "channel", "version"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
architecture: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
channel: Mapped[str] = mapped_column(Text, nullable=False) # "stable", "long-term", "testing"
|
||||
version: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
npk_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
npk_local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
npk_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
checked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FirmwareVersion arch={self.architecture} ch={self.channel} ver={self.version}>"
|
||||
|
||||
|
||||
class FirmwareUpgradeJob(Base):
|
||||
"""Tracks a firmware upgrade operation for a single device.
|
||||
|
||||
Multiple jobs can share a rollout_group_id for mass upgrades.
|
||||
"""
|
||||
__tablename__ = "firmware_upgrade_jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
rollout_group_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=True,
|
||||
)
|
||||
target_version: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
architecture: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
channel: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="pending", server_default="pending"
|
||||
)
|
||||
pre_upgrade_backup_sha: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
confirmed_major_upgrade: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FirmwareUpgradeJob id={self.id} status={self.status} target={self.target_version}>"
|
||||
134
backend/app/models/key_set.py
Normal file
134
backend/app/models/key_set.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Key set and key access log models for zero-knowledge architecture."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserKeySet(Base):
|
||||
"""Encrypted key bundle for a user.
|
||||
|
||||
Stores the RSA private key (wrapped by AUK), tenant vault key
|
||||
(wrapped by AUK), RSA public key, and key derivation salts.
|
||||
One key set per user (UNIQUE on user_id).
|
||||
"""
|
||||
|
||||
__tablename__ = "user_key_sets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True, # NULL for super_admin
|
||||
)
|
||||
encrypted_private_key: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
private_key_nonce: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
encrypted_vault_key: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
vault_key_nonce: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
public_key: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
pbkdf2_iterations: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
server_default=func.literal_column("650000"),
|
||||
nullable=False,
|
||||
)
|
||||
pbkdf2_salt: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
hkdf_salt: Mapped[bytes] = mapped_column(
|
||||
LargeBinary, nullable=False
|
||||
)
|
||||
key_version: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
server_default=func.literal_column("1"),
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User") # type: ignore[name-defined]
|
||||
tenant: Mapped["Tenant | None"] = relationship("Tenant") # type: ignore[name-defined]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserKeySet id={self.id} user_id={self.user_id} version={self.key_version}>"
|
||||
|
||||
|
||||
class KeyAccessLog(Base):
|
||||
"""Immutable audit trail for key operations.
|
||||
|
||||
Append-only: INSERT+SELECT only, no UPDATE/DELETE via RLS.
|
||||
"""
|
||||
|
||||
__tablename__ = "key_access_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
resource_type: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
resource_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
key_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Phase 29 extensions for device credential access tracking
|
||||
device_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id"),
|
||||
nullable=True,
|
||||
)
|
||||
justification: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
correlation_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<KeyAccessLog id={self.id} action={self.action!r}>"
|
||||
74
backend/app/models/maintenance_window.py
Normal file
74
backend/app/models/maintenance_window.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Maintenance window ORM model for scheduled maintenance periods.
|
||||
|
||||
Maintenance windows allow operators to define time periods during which
|
||||
alerts are suppressed for specific devices (or all devices in a tenant).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, VARCHAR, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MaintenanceWindow(Base):
|
||||
"""Scheduled maintenance window with optional alert suppression.
|
||||
|
||||
device_ids is a JSONB array of device UUID strings.
|
||||
An empty array means "all devices in tenant".
|
||||
"""
|
||||
__tablename__ = "maintenance_windows"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(VARCHAR(200), nullable=False)
|
||||
device_ids: Mapped[list] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default="'[]'::jsonb",
|
||||
)
|
||||
start_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
end_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
suppress_alerts: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
server_default="true",
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MaintenanceWindow id={self.id} name={self.name!r}>"
|
||||
49
backend/app/models/tenant.py
Normal file
49
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Tenant model — represents an MSP client organization."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, LargeBinary, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Zero-knowledge key management (Phase 28+29)
|
||||
encrypted_vault_key: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
vault_key_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
openbao_key_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships — passive_deletes=True lets the DB ON DELETE CASCADE handle cleanup
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined]
|
||||
devices: Mapped[list["Device"]] = relationship("Device", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined]
|
||||
device_groups: Mapped[list["DeviceGroup"]] = relationship("DeviceGroup", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined]
|
||||
device_tags: Mapped[list["DeviceTag"]] = relationship("DeviceTag", back_populates="tenant", passive_deletes=True) # type: ignore[name-defined]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant id={self.id} name={self.name!r}>"
|
||||
74
backend/app/models/user.py
Normal file
74
backend/app/models/user.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""User model with role-based access control."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, LargeBinary, SmallInteger, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""User roles with increasing privilege levels."""
|
||||
SUPER_ADMIN = "super_admin"
|
||||
TENANT_ADMIN = "tenant_admin"
|
||||
OPERATOR = "operator"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default=UserRole.VIEWER.value,
|
||||
)
|
||||
# tenant_id is nullable for super_admin users (portal-wide role)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
# SRP zero-knowledge authentication columns (nullable during migration period)
|
||||
srp_salt: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
srp_verifier: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
auth_version: Mapped[int] = mapped_column(
|
||||
SmallInteger, server_default=text("1"), nullable=False
|
||||
) # 1=bcrypt legacy, 2=SRP
|
||||
must_upgrade_auth: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default=text("false"), nullable=False
|
||||
) # True for bcrypt users who need SRP upgrade
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="users") # type: ignore[name-defined]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} email={self.email!r} role={self.role!r}>"
|
||||
85
backend/app/models/vpn.py
Normal file
85
backend/app/models/vpn.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""VPN configuration and peer models for WireGuard management."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class VpnConfig(Base):
|
||||
"""Per-tenant WireGuard server configuration."""
|
||||
|
||||
__tablename__ = "vpn_config"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
server_private_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
server_public_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
subnet: Mapped[str] = mapped_column(String(32), nullable=False, server_default="10.10.0.0/24")
|
||||
server_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default="51820")
|
||||
server_address: Mapped[str] = mapped_column(String(32), nullable=False, server_default="10.10.0.1/24")
|
||||
endpoint: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, onupdate=func.now()
|
||||
)
|
||||
|
||||
# Peers are queried separately via tenant_id — no ORM relationship needed
|
||||
|
||||
|
||||
class VpnPeer(Base):
|
||||
"""WireGuard peer representing a device's VPN connection."""
|
||||
|
||||
__tablename__ = "vpn_peers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
device_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("devices.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
peer_private_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
peer_public_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
preshared_key: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
|
||||
assigned_ip: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
additional_allowed_ips: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
|
||||
last_handshake: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, onupdate=func.now()
|
||||
)
|
||||
|
||||
# Config is queried separately via tenant_id — no ORM relationship needed
|
||||
Reference in New Issue
Block a user