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:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View 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
View 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}>"

View 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}>"

View 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}>"

View 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}>"
)

View 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}>"
)

View 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}>"

View 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")

View 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}>"

View 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}>"

View 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}>"

View 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}>"

View 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
View 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