diff --git a/gatehouse_app/models/ssh_ca/ssh_certificate.py b/gatehouse_app/models/ssh_ca/ssh_certificate.py index a76fbd4..3affb58 100644 --- a/gatehouse_app/models/ssh_ca/ssh_certificate.py +++ b/gatehouse_app/models/ssh_ca/ssh_certificate.py @@ -50,7 +50,7 @@ class SSHCertificate(BaseModel): certificate = db.Column(db.Text, nullable=False) # 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 cert_type = db.Column( db.Enum(CertType, values_callable=lambda x: [e.value for e in x]), @@ -103,6 +103,8 @@ class SSHCertificate(BaseModel): ) __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_validity", "valid_after", "valid_before"), db.Index("idx_cert_revoked", "revoked", "revoked_at"), diff --git a/gatehouse_app/models/zerotier/activation_session.py b/gatehouse_app/models/zerotier/activation_session.py index 8ee06cb..f26a9ef 100644 --- a/gatehouse_app/models/zerotier/activation_session.py +++ b/gatehouse_app/models/zerotier/activation_session.py @@ -45,14 +45,14 @@ class ActivationSession(BaseModel): index=True, ) authenticated_at = db.Column( - db.DateTime(timezone=True), + db.DateTime, nullable=False, ) expires_at = db.Column( - db.DateTime(timezone=True), + db.DateTime, nullable=False, ) - ended_at = db.Column(db.DateTime(timezone=True), nullable=True) + ended_at = db.Column(db.DateTime, nullable=True) end_reason = db.Column( db.Enum(ActivationEndReason, name="activation_end_reason"), nullable=True, diff --git a/gatehouse_app/models/zerotier/zerotier_membership.py b/gatehouse_app/models/zerotier/zerotier_membership.py index 4897056..ad2b7e9 100644 --- a/gatehouse_app/models/zerotier/zerotier_membership.py +++ b/gatehouse_app/models/zerotier/zerotier_membership.py @@ -51,8 +51,8 @@ class ZeroTierMembership(BaseModel): ) member_seen = 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) - last_synced_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, nullable=True) raw_controller_payload = db.Column(db.JSON, nullable=True) # Relationships diff --git a/migrations/versions/020_add_zerotier_models.py b/migrations/versions/020_add_zerotier_models.py index 5b15439..bba02a4 100644 --- a/migrations/versions/020_add_zerotier_models.py +++ b/migrations/versions/020_add_zerotier_models.py @@ -4,314 +4,23 @@ Revision ID: 020_zerotier Revises: 019_audit_varchar Create Date: 2026-03-19 -Tables created: - - portal_networks — manager-created ZeroTier network bindings - - devices — user-registered ZeroTier node endpoints - - user_network_approvals — durable manager approval records - - 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 +SUPERSEDED by 023_zerotier_drop_legacy which creates all ZeroTier tables +idempotently (with IF NOT EXISTS / if_not_exists=True). This migration is +kept as a no-op to preserve the Alembic revision chain for databases that +already have '020_zerotier' stamped (e.g. dev environments). """ -from alembic import op -import sqlalchemy as sa - revision = "020_zerotier" down_revision = "019_audit_varchar" branch_labels = 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(): - bind = op.get_bind() - dialect = bind.dialect.name - - # ── 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), - ) + # No-op — 023_zerotier_drop_legacy handles everything idempotently. + pass def downgrade(): - bind = op.get_bind() - dialect = bind.dialect.name - - 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") + # No-op — 023_zerotier_drop_legacy handles rollback. + pass diff --git a/migrations/versions/022_add_command_events.py b/migrations/versions/022_add_command_events.py new file mode 100644 index 0000000..a410af7 --- /dev/null +++ b/migrations/versions/022_add_command_events.py @@ -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 diff --git a/migrations/versions/023_zerotier_drop_legacy.py b/migrations/versions/023_zerotier_drop_legacy.py new file mode 100644 index 0000000..c1a1608 --- /dev/null +++ b/migrations/versions/023_zerotier_drop_legacy.py @@ -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}")) diff --git a/migrations/versions/024_fix_zerotier_schema.py b/migrations/versions/024_fix_zerotier_schema.py new file mode 100644 index 0000000..11c9aa2 --- /dev/null +++ b/migrations/versions/024_fix_zerotier_schema.py @@ -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}' + )) diff --git a/migrations/versions/025_fix_zt_timestamps.py b/migrations/versions/025_fix_zt_timestamps.py new file mode 100644 index 0000000..2b0029f --- /dev/null +++ b/migrations/versions/025_fix_zt_timestamps.py @@ -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' + )) diff --git a/migrations/versions/026_schema_cleanup.py b/migrations/versions/026_schema_cleanup.py new file mode 100644 index 0000000..15e5ef0 --- /dev/null +++ b/migrations/versions/026_schema_cleanup.py @@ -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") diff --git a/migrations/versions/027_fix_cert_serial_uniqueness.py b/migrations/versions/027_fix_cert_serial_uniqueness.py new file mode 100644 index 0000000..3441b91 --- /dev/null +++ b/migrations/versions/027_fix_cert_serial_uniqueness.py @@ -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, + )