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