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