Fix: DB Migration

This commit is contained in:
2026-03-23 17:51:55 +05:45
parent a7915c9328
commit 05eb092228
10 changed files with 1151 additions and 305 deletions
@@ -50,7 +50,7 @@ class SSHCertificate(BaseModel):
certificate = db.Column(db.Text, nullable=False) certificate = db.Column(db.Text, nullable=False)
# Certificate metadata # Certificate metadata
serial = db.Column(db.String(255), nullable=False, unique=True, index=True) serial = db.Column(db.String(255), nullable=False)
key_id = db.Column(db.String(255), nullable=False) # Usually user email key_id = db.Column(db.String(255), nullable=False) # Usually user email
cert_type = db.Column( cert_type = db.Column(
db.Enum(CertType, values_callable=lambda x: [e.value for e in x]), db.Enum(CertType, values_callable=lambda x: [e.value for e in x]),
@@ -103,6 +103,8 @@ class SSHCertificate(BaseModel):
) )
__table_args__ = ( __table_args__ = (
db.UniqueConstraint("ca_id", "serial", name="uq_ssh_certificates_ca_serial"),
db.Index("ix_ssh_certificates_serial", "serial"),
db.Index("idx_cert_user_status", "user_id", "status"), db.Index("idx_cert_user_status", "user_id", "status"),
db.Index("idx_cert_validity", "valid_after", "valid_before"), db.Index("idx_cert_validity", "valid_after", "valid_before"),
db.Index("idx_cert_revoked", "revoked", "revoked_at"), db.Index("idx_cert_revoked", "revoked", "revoked_at"),
@@ -45,14 +45,14 @@ class ActivationSession(BaseModel):
index=True, index=True,
) )
authenticated_at = db.Column( authenticated_at = db.Column(
db.DateTime(timezone=True), db.DateTime,
nullable=False, nullable=False,
) )
expires_at = db.Column( expires_at = db.Column(
db.DateTime(timezone=True), db.DateTime,
nullable=False, nullable=False,
) )
ended_at = db.Column(db.DateTime(timezone=True), nullable=True) ended_at = db.Column(db.DateTime, nullable=True)
end_reason = db.Column( end_reason = db.Column(
db.Enum(ActivationEndReason, name="activation_end_reason"), db.Enum(ActivationEndReason, name="activation_end_reason"),
nullable=True, nullable=True,
@@ -51,8 +51,8 @@ class ZeroTierMembership(BaseModel):
) )
member_seen = db.Column(db.Boolean, default=False, nullable=False) member_seen = db.Column(db.Boolean, default=False, nullable=False)
authorized = db.Column(db.Boolean, default=False, nullable=False) authorized = db.Column(db.Boolean, default=False, nullable=False)
join_seen_at = db.Column(db.DateTime(timezone=True), nullable=True) join_seen_at = db.Column(db.DateTime, nullable=True)
last_synced_at = db.Column(db.DateTime(timezone=True), nullable=True) last_synced_at = db.Column(db.DateTime, nullable=True)
raw_controller_payload = db.Column(db.JSON, nullable=True) raw_controller_payload = db.Column(db.JSON, nullable=True)
# Relationships # Relationships
+8 -299
View File
@@ -4,314 +4,23 @@ Revision ID: 020_zerotier
Revises: 019_audit_varchar Revises: 019_audit_varchar
Create Date: 2026-03-19 Create Date: 2026-03-19
Tables created: SUPERSEDED by 023_zerotier_drop_legacy which creates all ZeroTier tables
- portal_networks — manager-created ZeroTier network bindings idempotently (with IF NOT EXISTS / if_not_exists=True). This migration is
- devices — user-registered ZeroTier node endpoints kept as a no-op to preserve the Alembic revision chain for databases that
- user_network_approvals — durable manager approval records already have '020_zerotier' stamped (e.g. dev environments).
- device_network_memberships — per-device per-network workflow records
- activation_sessions — temporary activation windows
- zerotier_memberships — observed controller-side member state
- kill_switch_events — explicit rapid deactivation records
""" """
from alembic import op
import sqlalchemy as sa
revision = "020_zerotier" revision = "020_zerotier"
down_revision = "019_audit_varchar" down_revision = "019_audit_varchar"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def _pg_enum(enum_name: str, values: list[str]) -> sa.Enum:
return sa.Enum(*values, name=enum_name, create_type=False)
def upgrade(): def upgrade():
bind = op.get_bind() # No-op — 023_zerotier_drop_legacy handles everything idempotently.
dialect = bind.dialect.name pass
# ── 1. Enum types ─────────────────────────────────────────────────────────
if dialect == "postgresql":
op.execute("CREATE TYPE network_environment AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["production", "staging", "development", "lab"]
))
op.execute("CREATE TYPE network_request_mode AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["open", "approval_required", "invite_only"]
))
op.execute("CREATE TYPE approval_grant_type AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["requested", "assigned"]
))
op.execute("CREATE TYPE approval_state AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["pending", "approved", "rejected", "revoked", "suspended"]
))
op.execute("CREATE TYPE membership_state AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in [
"pending_device_registration",
"pending_request",
"pending_manager_approval",
"approved_inactive",
"joined_deauthorized",
"active_authorized",
"activation_expired",
"suspended",
"revoked",
"rejected",
]
))
op.execute("CREATE TYPE activation_end_reason AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in [
"expired", "logout", "kill_switch",
"manual_revoke", "approval_revoked", "admin_action",
]
))
op.execute("CREATE TYPE kill_switch_scope AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["organization", "global", "selected_networks"]
))
op.execute("CREATE TYPE device_status AS ENUM (%s)" % ", ".join(
f"'{v}'" for v in ["active", "inactive"]
))
# ── 2. portal_networks ────────────────────────────────────────────────────
op.create_table(
"portal_networks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("owner_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("zerotier_network_id", sa.String(16), nullable=False, index=True),
sa.Column(
"environment",
_pg_enum("network_environment", ["production", "staging", "development", "lab"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
),
sa.Column(
"request_mode",
_pg_enum("network_request_mode", ["open", "approval_required", "invite_only"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
),
sa.Column("default_activation_lifetime_minutes", sa.Integer, nullable=False, default=480),
sa.Column("max_activation_lifetime_minutes", sa.Integer, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, default=True),
)
op.create_index(
"ix_portal_networks_org_zt",
"portal_networks",
["organization_id", "zerotier_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 3. devices ───────────────────────────────────────────────────────────
op.create_table(
"devices",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("node_id", sa.String(10), nullable=False, index=True),
sa.Column("device_nickname", sa.String(255), nullable=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("asset_tag", sa.String(255), nullable=True),
sa.Column("serial_number", sa.String(255), nullable=True),
sa.Column(
"status",
_pg_enum("device_status", ["active", "inactive"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="active",
),
)
if dialect == "postgresql":
op.create_index(
"ix_devices_node_id_active",
"devices",
["node_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
else:
op.create_index("ix_devices_node_id", "devices", ["node_id"], unique=False)
# ── 4. user_network_approvals ─────────────────────────────────────────────
op.create_table(
"user_network_approvals",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False, index=True),
sa.Column("granted_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=True),
sa.Column(
"grant_type",
_pg_enum("approval_grant_type", ["requested", "assigned"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="requested",
),
sa.Column(
"state",
_pg_enum("approval_state", ["pending", "approved", "rejected", "revoked", "suspended"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="pending",
index=True,
),
sa.Column("justification", sa.Text, nullable=True),
)
op.create_index(
"ix_user_network_approvals_user_network",
"user_network_approvals",
["user_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 5. device_network_memberships ────────────────────────────────────────
op.create_table(
"device_network_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("device_id", sa.String(36), sa.ForeignKey("devices.id"), nullable=False, index=True),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False, index=True),
sa.Column("user_network_approval_id", sa.String(36), sa.ForeignKey("user_network_approvals.id"), nullable=True, index=True),
sa.Column(
"state",
_pg_enum(
"membership_state",
[
"pending_device_registration", "pending_request",
"pending_manager_approval", "approved_inactive",
"joined_deauthorized", "active_authorized",
"activation_expired", "suspended", "revoked", "rejected",
],
) if dialect == "postgresql" else sa.String(30),
nullable=False,
default="pending_device_registration",
index=True,
),
sa.Column("join_seen", sa.Boolean, nullable=False, default=False),
sa.Column("currently_authorized", sa.Boolean, nullable=False, default=False),
sa.Column("approved_for_activation", sa.Boolean, nullable=False, default=True),
)
op.create_index(
"ix_device_network_memberships_device_network",
"device_network_memberships",
["device_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 6. activation_sessions ────────────────────────────────────────────────
op.create_table(
"activation_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=False, index=True),
sa.Column("authenticated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"end_reason",
_pg_enum(
"activation_end_reason",
["expired", "logout", "kill_switch", "manual_revoke", "approval_revoked", "admin_action"],
) if dialect == "postgresql" else sa.String(20),
nullable=True,
),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
)
# ── 7. zerotier_memberships ───────────────────────────────────────────────
op.create_table(
"zerotier_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=True, index=True),
sa.Column("zerotier_network_id", sa.String(16), nullable=False, index=True),
sa.Column("node_id", sa.String(10), nullable=False, index=True),
sa.Column("member_seen", sa.Boolean, nullable=False, default=False),
sa.Column("authorized", sa.Boolean, nullable=False, default=False),
sa.Column("join_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("raw_controller_payload", sa.JSON, nullable=True),
)
op.create_index(
"ix_zerotier_memberships_network_node",
"zerotier_memberships",
["zerotier_network_id", "node_id"],
unique=True,
)
# ── 8. kill_switch_events ────────────────────────────────────────────────
op.create_table(
"kill_switch_events",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False, index=True),
sa.Column("target_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column(
"scope",
_pg_enum("kill_switch_scope", ["organization", "global", "selected_networks"]) if dialect == "postgresql"
else sa.String(20),
nullable=False,
default="organization",
),
sa.Column("triggered_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reason", sa.Text, nullable=True),
sa.Column("network_ids", sa.JSON, nullable=True),
)
def downgrade(): def downgrade():
bind = op.get_bind() # No-op — 023_zerotier_drop_legacy handles rollback.
dialect = bind.dialect.name pass
op.drop_table("kill_switch_events")
op.drop_table("zerotier_memberships")
op.drop_table("activation_sessions")
op.drop_table("device_network_memberships")
op.drop_table("user_network_approvals")
op.drop_table("devices")
op.drop_table("portal_networks")
if dialect == "postgresql":
op.execute("DROP TYPE IF EXISTS kill_switch_scope")
op.execute("DROP TYPE IF EXISTS device_status")
op.execute("DROP TYPE IF EXISTS activation_end_reason")
op.execute("DROP TYPE IF EXISTS membership_state")
op.execute("DROP TYPE IF EXISTS approval_state")
op.execute("DROP TYPE IF EXISTS approval_grant_type")
op.execute("DROP TYPE IF EXISTS network_request_mode")
op.execute("DROP TYPE IF EXISTS network_environment")
@@ -0,0 +1,29 @@
"""Merge zerotier + CA/sudo/api-key branches.
Revision ID: 022_add_command_events
Revises: 020_zerotier, 021_merge_heads
Create Date: 2026-03-09
Pure merge-point for 020_zerotier and 021_merge_heads.
Revision ID kept as-is for compatibility with production databases that
already have '022_add_command_events' stamped in alembic_version.
"""
from alembic import op
# ---------------------------------------------------------------------------
# revision identifiers
# ---------------------------------------------------------------------------
revision = "022_add_command_events"
down_revision = ("020_zerotier", "021_merge_heads")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -0,0 +1,393 @@
"""Apply ZeroTier tables and drop legacy SSH-session tables.
Revision ID: 023_apply_zerotier_drop_legacy_ssh_tables
Revises: 022_add_command_events
Create Date: 2026-03-22
CONTEXT
-------
Migration 020_zerotier was never applied to the production database — the
alembic_version stamp jumped directly from a pre-zerotier revision to
022_add_command_events. This migration catches the DB up by:
1. Creating all ZeroTier / Portal Network tables (idempotent — every
create_table uses if_not_exists=True so it is safe to run on a DB
that already has some of these tables).
2. Dropping the legacy SSH-session tables that no longer have
corresponding ORM models:
- command_events (dropped first — has FKs to servers + host_sessions)
- sudo_events (dropped first — has FK to host_sessions)
- host_sessions (dropped second — referenced by the two above)
- servers (dropped last)
All drops use IF EXISTS so the migration is also safe on a fresh DB
that ran 020_zerotier correctly (those tables would already be absent).
PROD SAFETY
-----------
- All create_table calls use if_not_exists=True.
- All drop_table calls use IF EXISTS via op.execute() for tables that may
or may not be present.
- No data migration; no destructive schema change on tables that still
have ORM models.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# ---------------------------------------------------------------------------
revision = "023_zerotier_drop_legacy"
down_revision = "022_add_command_events"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
def _table_exists(conn, table: str) -> bool:
return Inspector.from_engine(conn).has_table(table)
def _index_exists(conn, table: str, index: str) -> bool:
insp = Inspector.from_engine(conn)
return any(i["name"] == index for i in insp.get_indexes(table)) if _table_exists(conn, table) else False
def _type_exists(conn, type_name: str) -> bool:
result = conn.execute(
sa.text("SELECT 1 FROM pg_type WHERE typname = :t"),
{"t": type_name},
).scalar()
return bool(result)
def _pg_enum(name: str) -> sa.Text:
"""Return a plain Text column type for use inside create_table.
We rely on the enum type already existing in PostgreSQL (created above via
'CREATE TYPE ... IF NOT EXISTS'). Using sa.String avoids SQLAlchemy's
automatic 'CREATE TYPE' emission inside create_table, which would fail if
the type already exists. A cast via server_default / CHECK constraint is
not required — PostgreSQL accepts varchar literals for enum columns when
inserted from SQLAlchemy's ORM layer, which uses the Python Enum type map.
"""
return sa.String(40)
# ---------------------------------------------------------------------------
# upgrade
# ---------------------------------------------------------------------------
def upgrade():
conn = op.get_bind()
dialect = conn.dialect.name
# ── 1. Enum types (PostgreSQL only, idempotent) ───────────────────────────
if dialect == "postgresql":
enum_defs = {
"network_environment": ["production", "staging", "development", "lab"],
"network_request_mode": ["open", "approval_required", "invite_only"],
"approval_grant_type": ["requested", "assigned"],
"approval_state": ["pending", "approved", "rejected", "revoked", "suspended"],
"membership_state": [
"pending_device_registration", "pending_request",
"pending_manager_approval", "approved_inactive",
"joined_deauthorized", "active_authorized",
"activation_expired", "suspended", "revoked", "rejected",
],
"activation_end_reason": [
"expired", "logout", "kill_switch",
"manual_revoke", "approval_revoked", "admin_action",
],
"kill_switch_scope": ["organization", "global", "selected_networks"],
"device_status": ["active", "inactive"],
}
for type_name, values in enum_defs.items():
if not _type_exists(conn, type_name):
quoted = ", ".join(f"'{v}'" for v in values)
conn.execute(sa.text(f"CREATE TYPE {type_name} AS ENUM ({quoted})"))
# ── 2. portal_networks ────────────────────────────────────────────────────
op.create_table(
"portal_networks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("owner_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("zerotier_network_id", sa.String(16), nullable=False),
sa.Column("environment", sa.String(40), nullable=False),
sa.Column("request_mode", sa.String(40), nullable=False),
sa.Column("default_activation_lifetime_minutes", sa.Integer, nullable=False, server_default="480"),
sa.Column("max_activation_lifetime_minutes", sa.Integer, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
if_not_exists=True,
)
if not _index_exists(conn, "portal_networks", "ix_portal_networks_organization_id"):
op.create_index("ix_portal_networks_organization_id", "portal_networks", ["organization_id"])
if not _index_exists(conn, "portal_networks", "ix_portal_networks_zerotier_network_id"):
op.create_index("ix_portal_networks_zerotier_network_id", "portal_networks", ["zerotier_network_id"])
if not _index_exists(conn, "portal_networks", "ix_portal_networks_org_zt"):
op.create_index(
"ix_portal_networks_org_zt", "portal_networks",
["organization_id", "zerotier_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 3. devices ────────────────────────────────────────────────────────────
op.create_table(
"devices",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("node_id", sa.String(10), nullable=False),
sa.Column("device_nickname", sa.String(255), nullable=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("asset_tag", sa.String(255), nullable=True),
sa.Column("serial_number", sa.String(255), nullable=True),
sa.Column("status", sa.String(40), nullable=False, server_default="active"),
if_not_exists=True,
)
if not _index_exists(conn, "devices", "ix_devices_user_id"):
op.create_index("ix_devices_user_id", "devices", ["user_id"])
if not _index_exists(conn, "devices", "ix_devices_organization_id"):
op.create_index("ix_devices_organization_id", "devices", ["organization_id"])
if not _index_exists(conn, "devices", "ix_devices_node_id_active") and dialect == "postgresql":
op.create_index(
"ix_devices_node_id_active", "devices", ["node_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
elif not _index_exists(conn, "devices", "ix_devices_node_id") and dialect != "postgresql":
op.create_index("ix_devices_node_id", "devices", ["node_id"])
# ── 4. user_network_approvals ─────────────────────────────────────────────
op.create_table(
"user_network_approvals",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False),
sa.Column("granted_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=True),
sa.Column("grant_type", sa.String(40), nullable=False, server_default="requested"),
sa.Column("state", sa.String(40), nullable=False, server_default="pending"),
sa.Column("justification", sa.Text, nullable=True),
if_not_exists=True,
)
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_organization_id"):
op.create_index("ix_user_network_approvals_organization_id", "user_network_approvals", ["organization_id"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_id"):
op.create_index("ix_user_network_approvals_user_id", "user_network_approvals", ["user_id"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_portal_network_id"):
op.create_index("ix_user_network_approvals_portal_network_id", "user_network_approvals", ["portal_network_id"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_state"):
op.create_index("ix_user_network_approvals_state", "user_network_approvals", ["state"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_network"):
op.create_index(
"ix_user_network_approvals_user_network", "user_network_approvals",
["user_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 5. device_network_memberships ─────────────────────────────────────────
op.create_table(
"device_network_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("device_id", sa.String(36), sa.ForeignKey("devices.id"), nullable=False),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False),
sa.Column("user_network_approval_id", sa.String(36), sa.ForeignKey("user_network_approvals.id"), nullable=True),
sa.Column("state", sa.String(40), nullable=False, server_default="pending_device_registration"),
sa.Column("join_seen", sa.Boolean, nullable=False, server_default="false"),
sa.Column("currently_authorized", sa.Boolean, nullable=False, server_default="false"),
sa.Column("approved_for_activation", sa.Boolean, nullable=False, server_default="true"),
if_not_exists=True,
)
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_organization_id"):
op.create_index("ix_device_network_memberships_organization_id", "device_network_memberships", ["organization_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_user_id"):
op.create_index("ix_device_network_memberships_user_id", "device_network_memberships", ["user_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_id"):
op.create_index("ix_device_network_memberships_device_id", "device_network_memberships", ["device_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_portal_network_id"):
op.create_index("ix_device_network_memberships_portal_network_id", "device_network_memberships", ["portal_network_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_state"):
op.create_index("ix_device_network_memberships_state", "device_network_memberships", ["state"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_user_network_approval_id"):
op.create_index("ix_device_network_memberships_user_network_approval_id", "device_network_memberships", ["user_network_approval_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_network"):
op.create_index(
"ix_device_network_memberships_device_network", "device_network_memberships",
["device_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 6. activation_sessions ────────────────────────────────────────────────
op.create_table(
"activation_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=False),
sa.Column("authenticated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_reason", sa.String(40), nullable=True),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
if_not_exists=True,
)
if not _index_exists(conn, "activation_sessions", "ix_activation_sessions_organization_id"):
op.create_index("ix_activation_sessions_organization_id", "activation_sessions", ["organization_id"])
if not _index_exists(conn, "activation_sessions", "ix_activation_sessions_user_id"):
op.create_index("ix_activation_sessions_user_id", "activation_sessions", ["user_id"])
if not _index_exists(conn, "activation_sessions", "ix_activation_sessions_device_network_membership_id"):
op.create_index("ix_activation_sessions_device_network_membership_id", "activation_sessions", ["device_network_membership_id"])
# ── 7. zerotier_memberships ───────────────────────────────────────────────
op.create_table(
"zerotier_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=True),
sa.Column("zerotier_network_id", sa.String(16), nullable=False),
sa.Column("node_id", sa.String(10), nullable=False),
sa.Column("member_seen", sa.Boolean, nullable=False, server_default="false"),
sa.Column("authorized", sa.Boolean, nullable=False, server_default="false"),
sa.Column("join_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("raw_controller_payload", sa.JSON, nullable=True),
if_not_exists=True,
)
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_organization_id"):
op.create_index("ix_zerotier_memberships_organization_id", "zerotier_memberships", ["organization_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_device_network_membership_id"):
op.create_index("ix_zerotier_memberships_device_network_membership_id", "zerotier_memberships", ["device_network_membership_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_zerotier_network_id"):
op.create_index("ix_zerotier_memberships_zerotier_network_id", "zerotier_memberships", ["zerotier_network_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_node_id"):
op.create_index("ix_zerotier_memberships_node_id", "zerotier_memberships", ["node_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_network_node"):
op.create_index(
"ix_zerotier_memberships_network_node", "zerotier_memberships",
["zerotier_network_id", "node_id"],
unique=True,
)
# ── 8. kill_switch_events ────────────────────────────────────────────────
op.create_table(
"kill_switch_events",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("target_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("scope", sa.String(40), nullable=False, server_default="organization"),
sa.Column("triggered_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reason", sa.Text, nullable=True),
sa.Column("network_ids", sa.JSON, nullable=True),
if_not_exists=True,
)
if not _index_exists(conn, "kill_switch_events", "ix_kill_switch_events_organization_id"):
op.create_index("ix_kill_switch_events_organization_id", "kill_switch_events", ["organization_id"])
if not _index_exists(conn, "kill_switch_events", "ix_kill_switch_events_target_user_id"):
op.create_index("ix_kill_switch_events_target_user_id", "kill_switch_events", ["target_user_id"])
# ── 9. Drop legacy SSH-session tables (IF EXISTS — safe on fresh DBs) ─────
#
# Order matters due to FK constraints:
# command_events → servers, host_sessions
# sudo_events → host_sessions
# host_sessions → (nothing that still exists)
# servers → (nothing that still exists)
conn.execute(sa.text("DROP TABLE IF EXISTS command_events CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS sudo_events CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS host_sessions CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS servers CASCADE"))
# ---------------------------------------------------------------------------
# downgrade
# ---------------------------------------------------------------------------
def downgrade():
conn = op.get_bind()
dialect = conn.dialect.name
# Re-create the legacy tables (minimal — enough for FK integrity)
op.create_table(
"servers",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("hostname", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("ip_address", sa.String(64), nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
if_not_exists=True,
)
op.create_table(
"host_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("server_id", sa.String(36), sa.ForeignKey("servers.id"), nullable=False),
if_not_exists=True,
)
# Drop ZeroTier tables
op.drop_table("kill_switch_events", if_exists=True)
op.drop_table("zerotier_memberships", if_exists=True)
op.drop_table("activation_sessions", if_exists=True)
op.drop_table("device_network_memberships", if_exists=True)
op.drop_table("user_network_approvals", if_exists=True)
op.drop_table("devices", if_exists=True)
op.drop_table("portal_networks", if_exists=True)
# Drop ZeroTier enum types
if dialect == "postgresql":
for t in [
"kill_switch_scope", "device_status", "activation_end_reason",
"membership_state", "approval_state", "approval_grant_type",
"network_request_mode", "network_environment",
]:
conn.execute(sa.text(f"DROP TYPE IF EXISTS {t}"))
@@ -0,0 +1,291 @@
"""Fix ZeroTier table schema: enum types, unique constraints, indexes, drop cert_token.
Revision ID: 024_fix_zerotier_schema
Revises: 023_zerotier_drop_legacy
Create Date: 2026-03-22
Addresses all `db check` differences after 023:
- Cast VARCHAR(40) enum columns to their proper PostgreSQL enum types
(guarded — skipped if columns are already native enum, e.g. on a fresh DB
where 020_zerotier created them correctly)
- Replace partial unique indexes with named UniqueConstraints
- Fix devices.node_id partial index -> plain index
- Add UniqueConstraint on `id` for all new ZeroTier tables (BaseModel.unique=True)
- Drop orphan cert_token column and its index from ssh_certificates
"""
from alembic import op
import sqlalchemy as sa
revision = "024_fix_zerotier_schema"
down_revision = "023_zerotier_drop_legacy"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _col_data_type(conn, table: str, column: str) -> str | None:
"""Return the PostgreSQL data_type string for a column, or None."""
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row[0] if row else None
def _column_exists(conn, table: str, column: str) -> bool:
return _col_data_type(conn, table, column) is not None
def _index_exists(conn, table: str, index: str) -> bool:
from sqlalchemy.engine.reflection import Inspector
insp = Inspector.from_engine(conn)
return any(i["name"] == index for i in insp.get_indexes(table))
def _constraint_exists(conn, constraint: str) -> bool:
row = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = :c"
), {"c": constraint}).first()
return row is not None
def upgrade():
conn = op.get_bind()
# -------------------------------------------------------------------------
# 1. Cast VARCHAR(40) enum columns to proper PostgreSQL enum types.
# GUARDED: On a fresh DB, 020_zerotier already created these as native
# enum types. We only cast if the column is currently 'character varying'.
# -------------------------------------------------------------------------
enum_casts = [
("portal_networks", "environment", "network_environment", None),
("portal_networks", "request_mode", "network_request_mode", None),
("devices", "status", "device_status", "'active'::device_status"),
("device_network_memberships", "state", "membership_state", "'pending_device_registration'::membership_state"),
("user_network_approvals", "grant_type", "approval_grant_type", "'requested'::approval_grant_type"),
("user_network_approvals", "state", "approval_state", "'pending'::approval_state"),
("activation_sessions", "end_reason", "activation_end_reason", None),
("kill_switch_events", "scope", "kill_switch_scope", "'organization'::kill_switch_scope"),
]
for table, col, enum_type, new_default in enum_casts:
dtype = _col_data_type(conn, table, col)
if dtype == "character varying":
conn.execute(sa.text(f'ALTER TABLE "{table}" ALTER COLUMN "{col}" DROP DEFAULT'))
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE {enum_type} '
f'USING "{col}"::text::{enum_type}'
))
if new_default:
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" SET DEFAULT {new_default}'
))
elif dtype == "USER-DEFINED" and new_default:
# Already native enum (fresh DB path). Ensure server_default is set
# if 020 used `default=` (Python-side) instead of `server_default=`.
# This is harmless — SET DEFAULT is idempotent.
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" SET DEFAULT {new_default}'
))
# -------------------------------------------------------------------------
# 2. portal_networks: drop partial unique index, add named UniqueConstraint
# -------------------------------------------------------------------------
if _index_exists(conn, "portal_networks", "ix_portal_networks_org_zt"):
op.drop_index("ix_portal_networks_org_zt", table_name="portal_networks")
if not _constraint_exists(conn, "uix_org_zt_network_id"):
op.create_unique_constraint(
"uix_org_zt_network_id",
"portal_networks",
["organization_id", "zerotier_network_id"],
)
# -------------------------------------------------------------------------
# 3. device_network_memberships: drop partial unique index, add named UC
# -------------------------------------------------------------------------
if _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_network"):
op.drop_index("ix_device_network_memberships_device_network", table_name="device_network_memberships")
if not _constraint_exists(conn, "uix_device_network"):
op.create_unique_constraint(
"uix_device_network",
"device_network_memberships",
["device_id", "portal_network_id", "deleted_at"],
)
# -------------------------------------------------------------------------
# 4. user_network_approvals: drop partial unique index, add named UC
# -------------------------------------------------------------------------
if _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_network"):
op.drop_index("ix_user_network_approvals_user_network", table_name="user_network_approvals")
if not _constraint_exists(conn, "uix_user_network_approval"):
op.create_unique_constraint(
"uix_user_network_approval",
"user_network_approvals",
["user_id", "portal_network_id", "deleted_at"],
)
# -------------------------------------------------------------------------
# 5. zerotier_memberships: drop index, add named UniqueConstraint
# -------------------------------------------------------------------------
if _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_network_node"):
op.drop_index("ix_zerotier_memberships_network_node", table_name="zerotier_memberships")
if not _constraint_exists(conn, "uix_zt_network_node"):
op.create_unique_constraint(
"uix_zt_network_node",
"zerotier_memberships",
["zerotier_network_id", "node_id"],
)
# -------------------------------------------------------------------------
# 6. devices.node_id: drop partial unique index, add plain non-unique index
# -------------------------------------------------------------------------
if _index_exists(conn, "devices", "ix_devices_node_id_active"):
op.drop_index("ix_devices_node_id_active", table_name="devices")
if not _index_exists(conn, "devices", "ix_devices_node_id"):
op.create_index("ix_devices_node_id", "devices", ["node_id"])
# -------------------------------------------------------------------------
# 7. Add UniqueConstraint on `id` for all ZeroTier tables
# BaseModel defines id with unique=True → separate _id_key constraint.
# -------------------------------------------------------------------------
zt_tables = [
"portal_networks",
"devices",
"device_network_memberships",
"user_network_approvals",
"activation_sessions",
"zerotier_memberships",
"kill_switch_events",
]
for tbl in zt_tables:
cname = f"{tbl}_id_key"
if not _constraint_exists(conn, cname):
op.create_unique_constraint(cname, tbl, ["id"])
# -------------------------------------------------------------------------
# 8. Drop orphan cert_token column and its index from ssh_certificates.
# cert_token was created by 3de11c5dc2d5 but the SSHCertificate model
# never uses it. Guarded in case a future revision removes it first.
# -------------------------------------------------------------------------
if _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_cert_token"):
op.drop_index("ix_ssh_certificates_cert_token", table_name="ssh_certificates")
if _column_exists(conn, "ssh_certificates", "cert_token"):
op.drop_column("ssh_certificates", "cert_token")
def downgrade():
conn = op.get_bind()
# Restore cert_token if it was dropped
if not _column_exists(conn, "ssh_certificates", "cert_token"):
op.add_column(
"ssh_certificates",
sa.Column("cert_token", sa.String(64), nullable=True),
)
if not _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_cert_token"):
op.create_index(
"ix_ssh_certificates_cert_token",
"ssh_certificates",
["cert_token"],
unique=True,
)
# Drop id unique constraints on ZeroTier tables
zt_tables = [
"portal_networks",
"devices",
"device_network_memberships",
"user_network_approvals",
"activation_sessions",
"zerotier_memberships",
"kill_switch_events",
]
for tbl in zt_tables:
cname = f"{tbl}_id_key"
if _constraint_exists(conn, cname):
op.drop_constraint(cname, tbl, type_="unique")
# Restore devices node_id index
if _index_exists(conn, "devices", "ix_devices_node_id"):
op.drop_index("ix_devices_node_id", table_name="devices")
if not _index_exists(conn, "devices", "ix_devices_node_id_active"):
op.create_index(
"ix_devices_node_id_active",
"devices",
["node_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Restore zerotier_memberships index
if _constraint_exists(conn, "uix_zt_network_node"):
op.drop_constraint("uix_zt_network_node", "zerotier_memberships", type_="unique")
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_network_node"):
op.create_index(
"ix_zerotier_memberships_network_node",
"zerotier_memberships",
["zerotier_network_id", "node_id"],
unique=True,
)
# Restore user_network_approvals partial unique index
if _constraint_exists(conn, "uix_user_network_approval"):
op.drop_constraint("uix_user_network_approval", "user_network_approvals", type_="unique")
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_network"):
op.create_index(
"ix_user_network_approvals_user_network",
"user_network_approvals",
["user_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Restore device_network_memberships partial unique index
if _constraint_exists(conn, "uix_device_network"):
op.drop_constraint("uix_device_network", "device_network_memberships", type_="unique")
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_network"):
op.create_index(
"ix_device_network_memberships_device_network",
"device_network_memberships",
["device_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Restore portal_networks partial unique index
if _constraint_exists(conn, "uix_org_zt_network_id"):
op.drop_constraint("uix_org_zt_network_id", "portal_networks", type_="unique")
if not _index_exists(conn, "portal_networks", "ix_portal_networks_org_zt"):
op.create_index(
"ix_portal_networks_org_zt",
"portal_networks",
["organization_id", "zerotier_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Cast enum columns back to VARCHAR(40) — only if currently native enum
enum_casts = [
("portal_networks", "environment", "'development'::character varying"),
("portal_networks", "request_mode", "'approval_required'::character varying"),
("devices", "status", "'active'::character varying"),
("device_network_memberships", "state", "'pending_device_registration'::character varying"),
("user_network_approvals", "grant_type", "'requested'::character varying"),
("user_network_approvals", "state", "'pending'::character varying"),
("activation_sessions", "end_reason", None),
("kill_switch_events", "scope", "'organization'::character varying"),
]
for table, col, old_default in enum_casts:
conn.execute(sa.text(f'ALTER TABLE "{table}" ALTER COLUMN "{col}" DROP DEFAULT'))
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE VARCHAR(40) '
f'USING "{col}"::text'
))
if old_default:
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" SET DEFAULT {old_default}'
))
@@ -0,0 +1,101 @@
"""Convert ZeroTier table timestamp columns from TIMESTAMPTZ to TIMESTAMP.
Revision ID: 025_fix_zt_timestamps
Revises: 024_fix_zerotier_schema
Create Date: 2026-03-22
Migration 020_zerotier (and 023's fallback create_table) defined ZeroTier tables
with sa.DateTime(timezone=True), producing TIMESTAMP WITH TIME ZONE columns.
The rest of the codebase uses plain DateTime (timezone-naive TIMESTAMP WITHOUT
TIME ZONE). This migration aligns all ZeroTier table timestamp columns with the
existing codebase convention.
GUARDED: Each ALTER is only executed if the column is currently
TIMESTAMP WITH TIME ZONE. On a DB that has already been converted (e.g. dev),
the migration is a harmless no-op.
"""
from alembic import op
import sqlalchemy as sa
revision = "025_fix_zt_timestamps"
down_revision = "024_fix_zerotier_schema"
branch_labels = None
depends_on = None
# All ZeroTier tables that inherit BaseModel's created_at/updated_at/deleted_at
_ZT_BASE_TABLES = [
"portal_networks",
"devices",
"device_network_memberships",
"user_network_approvals",
"kill_switch_events",
"activation_sessions",
"zerotier_memberships",
]
# Additional datetime columns specific to individual models
_EXTRA_COLS = {
"activation_sessions": ["authenticated_at", "expires_at", "ended_at"],
"zerotier_memberships": ["join_seen_at", "last_synced_at"],
}
def _col_is_timestamptz(conn, table: str, column: str) -> bool:
"""Return True if the column is TIMESTAMP WITH TIME ZONE."""
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row is not None and row[0] == "timestamp with time zone"
def _col_is_timestamp(conn, table: str, column: str) -> bool:
"""Return True if the column is TIMESTAMP WITHOUT TIME ZONE."""
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row is not None and row[0] == "timestamp without time zone"
def upgrade():
conn = op.get_bind()
for tbl in _ZT_BASE_TABLES:
for col in ("created_at", "updated_at", "deleted_at"):
if _col_is_timestamptz(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITHOUT TIME ZONE '
f'USING "{col}" AT TIME ZONE \'UTC\''
))
for col in _EXTRA_COLS.get(tbl, []):
if _col_is_timestamptz(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITHOUT TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
def downgrade():
conn = op.get_bind()
for tbl in _ZT_BASE_TABLES:
for col in ("created_at", "updated_at", "deleted_at"):
if _col_is_timestamp(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITH TIME ZONE '
f'USING "{col}" AT TIME ZONE \'UTC\''
))
for col in _EXTRA_COLS.get(tbl, []):
if _col_is_timestamp(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITH TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
+216
View File
@@ -0,0 +1,216 @@
"""Schema cleanup: id UniqueConstraints, organization_api_keys index/timestamp fixes.
Revision ID: 026_schema_cleanup
Revises: 025_fix_zt_timestamps
Create Date: 2026-03-23
Addresses all `db check` differences after 025 on a database upgraded from
production (021_merge_heads):
1. Add UniqueConstraint on `id` for all pre-existing tables that inherit
BaseModel (which declares id with unique=True). The ZeroTier tables
already got these in 024_fix_zerotier_schema; this covers the rest.
2. organization_api_keys fix schema drift vs. the current model:
- TIMESTAMPTZ TIMESTAMP WITHOUT TIME ZONE (align with rest of codebase)
- Drop legacy unique constraint 'organization_api_keys_key_hash_key'
and replace with named index 'ix_organization_api_keys_key_hash'
- Drop extra index 'idx_org_api_key_org_id' (superseded by
'ix_organization_api_keys_organization_id')
- Add 'ix_organization_api_keys_organization_id' and
'ix_organization_api_keys_is_revoked' named indexes expected by model
3. Drop 'idx_dept_can_sudo' index from departments created by an old
migration but not declared in the current Department model.
All operations are guarded so the migration is safe to re-run.
"""
from alembic import op
import sqlalchemy as sa
revision = "026_schema_cleanup"
down_revision = "025_fix_zt_timestamps"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _constraint_exists(conn, name: str) -> bool:
row = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = :n"
), {"n": name}).first()
return row is not None
def _index_exists(conn, table: str, index: str) -> bool:
row = conn.execute(sa.text(
"SELECT 1 FROM pg_indexes "
"WHERE tablename = :t AND indexname = :i"
), {"t": table, "i": index}).first()
return row is not None
def _col_is_timestamptz(conn, table: str, column: str) -> bool:
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row is not None and row[0] == "timestamp with time zone"
# ---------------------------------------------------------------------------
# Tables that inherit BaseModel and need an id UniqueConstraint.
# ZeroTier tables were handled in 024; all others are listed here.
# ---------------------------------------------------------------------------
_LEGACY_TABLES = [
"application_provider_configs",
"audit_logs",
"authentication_methods",
"ca_permissions",
"cas",
"certificate_audit_logs",
"department_cert_policies",
"department_memberships",
"department_principals",
"departments",
"email_verification_tokens",
"external_provider_configs",
"mfa_policy_compliance",
"oauth_states",
"oidc_audit_logs",
"oidc_authorization_codes",
"oidc_clients",
"oidc_refresh_tokens",
"oidc_sessions",
# oidc_token_metadata intentionally excluded: its id column overrides
# BaseModel without unique=True (JTI is the PK but not separately unique)
"org_invite_tokens",
"organization_api_keys",
"organization_members",
"organization_provider_overrides",
"organization_security_policies",
"organizations",
"password_reset_tokens",
"principal_memberships",
"principals",
"sessions",
"ssh_certificates",
"ssh_keys",
"user_security_policies",
"users",
]
def upgrade():
conn = op.get_bind()
# ── 1. Add id UniqueConstraint to all legacy BaseModel tables ─────────
for tbl in _LEGACY_TABLES:
cname = f"{tbl}_id_key"
if not _constraint_exists(conn, cname):
op.create_unique_constraint(cname, tbl, ["id"])
# Drop the wrongly-added constraint on oidc_token_metadata if present
# (its id column overrides BaseModel without unique=True)
if _constraint_exists(conn, "oidc_token_metadata_id_key"):
op.drop_constraint("oidc_token_metadata_id_key", "oidc_token_metadata", type_="unique")
# ── 2. organization_api_keys: timestamp columns TIMESTAMPTZ → TIMESTAMP
for col in ("created_at", "updated_at", "deleted_at", "last_used_at", "revoked_at"):
if _col_is_timestamptz(conn, "organization_api_keys", col):
conn.execute(sa.text(
f'ALTER TABLE organization_api_keys ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITHOUT TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
# ── 3. organization_api_keys: replace legacy unique constraint + indexes
# Drop the anonymous unique constraint on key_hash (created by
# sa.UniqueConstraint('key_hash') in the original migration)
if _constraint_exists(conn, "organization_api_keys_key_hash_key"):
op.drop_constraint(
"organization_api_keys_key_hash_key",
"organization_api_keys",
type_="unique",
)
# Add named unique index for key_hash expected by the model
if not _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_key_hash"):
op.create_index(
"ix_organization_api_keys_key_hash",
"organization_api_keys",
["key_hash"],
unique=True,
)
# Drop the legacy plain org-id index (superseded by the named one below)
if _index_exists(conn, "organization_api_keys", "idx_org_api_key_org_id"):
op.drop_index("idx_org_api_key_org_id", table_name="organization_api_keys")
# Add named org-id index expected by the model
if not _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_organization_id"):
op.create_index(
"ix_organization_api_keys_organization_id",
"organization_api_keys",
["organization_id"],
)
# Add named is_revoked index expected by the model
if not _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_is_revoked"):
op.create_index(
"ix_organization_api_keys_is_revoked",
"organization_api_keys",
["is_revoked"],
)
# ── 4. Drop orphan idx_dept_can_sudo from departments ─────────────────
if _index_exists(conn, "departments", "idx_dept_can_sudo"):
op.drop_index("idx_dept_can_sudo", table_name="departments")
# NOTE: ix_ssh_certificates_serial uniqueness is handled in
# 027_fix_cert_serial_uniqueness (composite unique per CA).
def downgrade():
conn = op.get_bind()
# Restore idx_dept_can_sudo
if not _index_exists(conn, "departments", "idx_dept_can_sudo"):
op.create_index("idx_dept_can_sudo", "departments", ["organization_id", "can_sudo"])
# Restore organization_api_keys indexes
if _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_is_revoked"):
op.drop_index("ix_organization_api_keys_is_revoked", table_name="organization_api_keys")
if _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_organization_id"):
op.drop_index("ix_organization_api_keys_organization_id", table_name="organization_api_keys")
if _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_key_hash"):
op.drop_index("ix_organization_api_keys_key_hash", table_name="organization_api_keys")
if not _constraint_exists(conn, "organization_api_keys_key_hash_key"):
op.create_unique_constraint(
"organization_api_keys_key_hash_key",
"organization_api_keys",
["key_hash"],
)
if not _index_exists(conn, "organization_api_keys", "idx_org_api_key_org_id"):
op.create_index("idx_org_api_key_org_id", "organization_api_keys", ["organization_id"])
# Restore TIMESTAMPTZ on organization_api_keys
for col in ("created_at", "updated_at", "deleted_at", "last_used_at", "revoked_at"):
conn.execute(sa.text(
f'ALTER TABLE organization_api_keys ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITH TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
# Drop id UniqueConstraints from legacy tables
for tbl in reversed(_LEGACY_TABLES):
cname = f"{tbl}_id_key"
if _constraint_exists(conn, cname):
op.drop_constraint(cname, tbl, type_="unique")
@@ -0,0 +1,105 @@
"""Fix ssh_certificates serial uniqueness: per-CA not global.
Revision ID: 027_fix_cert_serial_uniqueness
Revises: 026_schema_cleanup
Create Date: 2026-03-23
The SSHCertificate model uses a per-CA monotonic serial counter, meaning
serial numbers are only unique within a single CA not across the whole
table. The original migration created a global unique index on `serial`
alone, which is incorrect and was blocking enforcement (duplicate serial=1
rows exist in production where two different CAs both issued their first
certificate).
This migration:
1. Drops the old non-unique index ix_ssh_certificates_serial (which was
never enforcing uniqueness just an index).
2. Drops the stale unique constraint ssh_certificates_serial_key if it
somehow exists.
3. Creates a proper composite unique constraint uq_ssh_certificates_ca_serial
on (ca_id, serial), reflecting the real invariant: a serial is unique
within one CA.
All operations are guarded (IF EXISTS / try/except) so this is safe to
re-run on any DB state.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# ---------------------------------------------------------------------------
# revision identifiers
# ---------------------------------------------------------------------------
revision = "027_fix_cert_serial_uniqueness"
down_revision = "026_schema_cleanup"
branch_labels = None
depends_on = None
def _index_exists(conn, table: str, index: str) -> bool:
insp = Inspector.from_engine(conn)
return any(i["name"] == index for i in insp.get_indexes(table))
def _constraint_exists(conn, table: str, constraint: str) -> bool:
insp = Inspector.from_engine(conn)
for uc in insp.get_unique_constraints(table):
if uc["name"] == constraint:
return True
return False
def upgrade():
conn = op.get_bind()
# 1. Drop the old global non-unique index on serial (if present)
if _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_serial"):
op.drop_index("ix_ssh_certificates_serial", table_name="ssh_certificates")
# 2. Drop any stale global unique constraint on serial alone (defensive)
if _constraint_exists(conn, "ssh_certificates", "ssh_certificates_serial_key"):
op.drop_constraint(
"ssh_certificates_serial_key",
"ssh_certificates",
type_="unique",
)
# 3. Add composite unique constraint: serial is unique per CA
if not _constraint_exists(conn, "ssh_certificates", "uq_ssh_certificates_ca_serial"):
op.create_unique_constraint(
"uq_ssh_certificates_ca_serial",
"ssh_certificates",
["ca_id", "serial"],
)
# 4. Re-create a plain non-unique index on serial for fast lookups
if not _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_serial"):
op.create_index(
"ix_ssh_certificates_serial",
"ssh_certificates",
["serial"],
unique=False,
)
def downgrade():
conn = op.get_bind()
# Remove the composite constraint
if _constraint_exists(conn, "ssh_certificates", "uq_ssh_certificates_ca_serial"):
op.drop_constraint(
"uq_ssh_certificates_ca_serial",
"ssh_certificates",
type_="unique",
)
# Restore the old non-unique index (best effort — data may have duplicates)
if not _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_serial"):
op.create_index(
"ix_ssh_certificates_serial",
"ssh_certificates",
["serial"],
unique=False,
)