Files
gatehouse-api/migrations/versions/023_zerotier_drop_legacy.py
T

394 lines
22 KiB
Python
Raw Normal View History

2026-03-23 17:51:55 +05:45
"""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}"))