"""Merge user_network_approvals and device_network_memberships into network_access_requests. Revision ID: c0a1b2c3d4e5 Revises: a1b2c3d4e5f6 Create Date: 2026-05-02 00:00:00.000000 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'c0a1b2c3d4e5' down_revision = 'a1b2c3d4e5f6' branch_labels = None depends_on = None # --------------------------------------------------------------------------- # UPGRADE # --------------------------------------------------------------------------- def upgrade(): # ------------------------------------------------------------------ # Step 1: Create the new network_access_requests table # ------------------------------------------------------------------ op.create_table( 'network_access_requests', sa.Column('id', sa.String(length=36), nullable=False), sa.Column('organization_id', sa.String(length=36), nullable=False), sa.Column('user_id', sa.String(length=36), nullable=False), sa.Column('device_id', sa.String(length=36), nullable=False), sa.Column('portal_network_id', sa.String(length=36), nullable=False), sa.Column('granted_by_user_id', sa.String(length=36), nullable=True), sa.Column( 'grant_type', sa.Enum('requested', 'assigned', name='approval_grant_type', create_type=False), nullable=False, ), sa.Column( 'status', sa.Enum( 'pending', 'approved', 'rejected', 'revoked', 'suspended', name='approval_state', create_type=False, ), nullable=False, ), sa.Column('active', sa.Boolean(), nullable=False, server_default='false'), sa.Column('justification', sa.Text(), nullable=True), sa.Column('join_seen', sa.Boolean(), nullable=False, server_default='false'), 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.ForeignKeyConstraint( ['device_id'], ['devices.id'], name='fk_network_access_requests_device', ), sa.ForeignKeyConstraint( ['granted_by_user_id'], ['users.id'], name='fk_network_access_requests_granted_by_user', ), sa.ForeignKeyConstraint( ['organization_id'], ['organizations.id'], name='fk_network_access_requests_organization', ), sa.ForeignKeyConstraint( ['portal_network_id'], ['portal_networks.id'], name='fk_network_access_requests_portal_network', ), sa.ForeignKeyConstraint( ['user_id'], ['users.id'], name='fk_network_access_requests_user', ), sa.PrimaryKeyConstraint('id', name='pk_network_access_requests'), sa.UniqueConstraint( 'user_id', 'device_id', 'portal_network_id', 'deleted_at', name='uix_user_device_network', ), ) # Indexes on network_access_requests op.create_index( 'ix_network_access_requests_device_id', 'network_access_requests', ['device_id'], unique=False, ) op.create_index( 'ix_network_access_requests_organization_id', 'network_access_requests', ['organization_id'], unique=False, ) op.create_index( 'ix_network_access_requests_portal_network_id', 'network_access_requests', ['portal_network_id'], unique=False, ) op.create_index( 'ix_network_access_requests_status', 'network_access_requests', ['status'], unique=False, ) op.create_index( 'ix_network_access_requests_user_id', 'network_access_requests', ['user_id'], unique=False, ) # ------------------------------------------------------------------ # Step 2: Migrate data from old tables into the new table # ------------------------------------------------------------------ op.execute( """ INSERT INTO network_access_requests ( id, organization_id, user_id, device_id, portal_network_id, granted_by_user_id, grant_type, status, active, justification, join_seen, created_at, updated_at, deleted_at ) SELECT dnm.id, dnm.organization_id, dnm.user_id, dnm.device_id, dnm.portal_network_id, COALESCE(una.granted_by_user_id, NULL), COALESCE(una.grant_type, 'requested'), COALESCE(una.state, 'pending'), CASE WHEN dnm.currently_authorized = true AND una.state = 'approved' THEN true ELSE false END, una.justification, dnm.join_seen, COALESCE(dnm.created_at, una.created_at), COALESCE(dnm.updated_at, una.updated_at), dnm.deleted_at FROM device_network_memberships dnm LEFT JOIN user_network_approvals una ON una.id = dnm.user_network_approval_id; """ ) # ------------------------------------------------------------------ # Step 3: Update activation_sessions FK # ------------------------------------------------------------------ # 3a. Add the new nullable column op.add_column( 'activation_sessions', sa.Column('network_access_request_id', sa.String(length=36), nullable=True), ) # 3b. Populate the new column from the old column op.execute( """ UPDATE activation_sessions SET network_access_request_id = device_network_membership_id; """ ) # 3c. Drop the old foreign-key constraint op.drop_constraint( 'activation_sessions_device_network_membership_id_fkey', 'activation_sessions', type_='foreignkey', ) # 3d. Drop the old column op.drop_column('activation_sessions', 'device_network_membership_id') # 3d-alt. Enforce NOT NULL on the new column before FK creation op.alter_column('activation_sessions', 'network_access_request_id', nullable=False) # 3e. Create the new foreign-key constraint op.create_foreign_key( 'fk_activation_sessions_network_access_request', 'activation_sessions', 'network_access_requests', ['network_access_request_id'], ['id'], ) # 3f. Create the new index op.create_index( 'ix_activation_sessions_network_access_request_id', 'activation_sessions', ['network_access_request_id'], unique=False, ) # ------------------------------------------------------------------ # Step 4: Update zerotier_memberships FK # ------------------------------------------------------------------ # 4a. Add the new nullable column op.add_column( 'zerotier_memberships', sa.Column('network_access_request_id', sa.String(length=36), nullable=True), ) # 4b. Populate the new column from the old column op.execute( """ UPDATE zerotier_memberships SET network_access_request_id = device_network_membership_id; """ ) # 4c. Drop the old foreign-key constraint op.drop_constraint( 'zerotier_memberships_device_network_membership_id_fkey', 'zerotier_memberships', type_='foreignkey', ) # 4d. Drop the old column op.drop_column('zerotier_memberships', 'device_network_membership_id') # 4e. Create the new foreign-key constraint op.create_foreign_key( 'fk_zerotier_memberships_network_access_request', 'zerotier_memberships', 'network_access_requests', ['network_access_request_id'], ['id'], ) # 4f. Create the new index op.create_index( 'ix_zerotier_memberships_network_access_request_id', 'zerotier_memberships', ['network_access_request_id'], unique=False, ) # ------------------------------------------------------------------ # Step 5: Drop old tables and the membership_state enum # ------------------------------------------------------------------ # 5a. Drop device_network_memberships and all its indexes op.drop_index( 'ix_device_network_memberships_user_network_approval_id', table_name='device_network_memberships', ) op.drop_index( 'ix_device_network_memberships_user_id', table_name='device_network_memberships', ) op.drop_index( 'ix_device_network_memberships_state', table_name='device_network_memberships', ) op.drop_index( 'ix_device_network_memberships_portal_network_id', table_name='device_network_memberships', ) op.drop_index( 'ix_device_network_memberships_organization_id', table_name='device_network_memberships', ) op.drop_index( 'ix_device_network_memberships_device_id', table_name='device_network_memberships', ) op.drop_table('device_network_memberships') # 5b. Drop user_network_approvals and all its indexes op.drop_index( 'ix_user_network_approvals_user_id', table_name='user_network_approvals', ) op.drop_index( 'ix_user_network_approvals_state', table_name='user_network_approvals', ) op.drop_index( 'ix_user_network_approvals_portal_network_id', table_name='user_network_approvals', ) op.drop_index( 'ix_user_network_approvals_organization_id', table_name='user_network_approvals', ) op.drop_table('user_network_approvals') # 5c. Drop the membership_state enum type if it exists op.execute( """ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_type WHERE typname = 'membership_state' ) THEN DROP TYPE membership_state; END IF; END$$; """ ) # --------------------------------------------------------------------------- # DOWNGRADE # --------------------------------------------------------------------------- def downgrade(): # ------------------------------------------------------------------ # Step 1: Recreate the membership_state enum (used by old tables) # ------------------------------------------------------------------ membership_state = sa.Enum( 'pending_device_registration', 'pending_request', 'pending_manager_approval', 'approved_inactive', 'joined_deauthorized', 'active_authorized', 'activation_expired', 'suspended', 'revoked', 'rejected', name='membership_state', ) membership_state.create(op.get_bind(), checkfirst=True) # ------------------------------------------------------------------ # Step 2: Recreate user_network_approvals table # ------------------------------------------------------------------ op.create_table( 'user_network_approvals', sa.Column('organization_id', sa.String(length=36), nullable=False), sa.Column('user_id', sa.String(length=36), nullable=False), sa.Column('portal_network_id', sa.String(length=36), nullable=False), sa.Column('granted_by_user_id', sa.String(length=36), nullable=True), sa.Column( 'grant_type', sa.Enum('requested', 'assigned', name='approval_grant_type', create_type=False), nullable=False, ), sa.Column( 'state', sa.Enum( 'pending', 'approved', 'rejected', 'revoked', 'suspended', name='approval_state', create_type=False, ), nullable=False, ), sa.Column('justification', sa.Text(), nullable=True), sa.Column('id', sa.String(length=36), nullable=False), 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.ForeignKeyConstraint( ['granted_by_user_id'], ['users.id'], ), sa.ForeignKeyConstraint( ['organization_id'], ['organizations.id'], ), sa.ForeignKeyConstraint( ['portal_network_id'], ['portal_networks.id'], ), sa.ForeignKeyConstraint( ['user_id'], ['users.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint( 'user_id', 'portal_network_id', 'deleted_at', name='uix_user_network_approval', ), ) # Recreate indexes on user_network_approvals op.create_index( 'ix_user_network_approvals_organization_id', 'user_network_approvals', ['organization_id'], unique=False, ) op.create_index( 'ix_user_network_approvals_portal_network_id', 'user_network_approvals', ['portal_network_id'], unique=False, ) op.create_index( 'ix_user_network_approvals_state', 'user_network_approvals', ['state'], unique=False, ) op.create_index( 'ix_user_network_approvals_user_id', 'user_network_approvals', ['user_id'], unique=False, ) # ------------------------------------------------------------------ # Step 3: Migrate data back into user_network_approvals # ------------------------------------------------------------------ # Derive one approval row per (user_id, portal_network_id, deleted_at). # We use gen_random_uuid() to generate new approval IDs because the # original approval IDs were lost during the upgrade. op.execute( """ INSERT INTO user_network_approvals ( id, organization_id, user_id, portal_network_id, granted_by_user_id, grant_type, state, justification, created_at, updated_at, deleted_at ) SELECT gen_random_uuid()::text, (array_agg(organization_id ORDER BY created_at))[1], user_id, portal_network_id, (array_agg(granted_by_user_id ORDER BY created_at))[1], (array_agg(grant_type ORDER BY created_at))[1], (array_agg(status ORDER BY created_at))[1], (array_agg(justification ORDER BY created_at))[1], MIN(created_at), MAX(updated_at), deleted_at FROM network_access_requests GROUP BY user_id, portal_network_id, deleted_at; """ ) # ------------------------------------------------------------------ # Step 4: Recreate device_network_memberships table # ------------------------------------------------------------------ op.create_table( 'device_network_memberships', sa.Column('organization_id', sa.String(length=36), nullable=False), sa.Column('user_id', sa.String(length=36), nullable=False), sa.Column('device_id', sa.String(length=36), nullable=False), sa.Column('portal_network_id', sa.String(length=36), nullable=False), sa.Column('user_network_approval_id', sa.String(length=36), nullable=True), sa.Column( 'state', sa.Enum( 'pending_device_registration', 'pending_request', 'pending_manager_approval', 'approved_inactive', 'joined_deauthorized', 'active_authorized', 'activation_expired', 'suspended', 'revoked', 'rejected', name='membership_state', create_type=False, ), nullable=False, ), sa.Column('join_seen', sa.Boolean(), nullable=False), sa.Column('currently_authorized', sa.Boolean(), nullable=False), sa.Column('approved_for_activation', sa.Boolean(), nullable=False), sa.Column('id', sa.String(length=36), nullable=False), 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.ForeignKeyConstraint( ['device_id'], ['devices.id'], ), sa.ForeignKeyConstraint( ['organization_id'], ['organizations.id'], ), sa.ForeignKeyConstraint( ['portal_network_id'], ['portal_networks.id'], ), sa.ForeignKeyConstraint( ['user_id'], ['users.id'], ), sa.ForeignKeyConstraint( ['user_network_approval_id'], ['user_network_approvals.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint( 'device_id', 'portal_network_id', 'deleted_at', name='uix_device_network', ), ) # Recreate indexes on device_network_memberships op.create_index( 'ix_device_network_memberships_device_id', 'device_network_memberships', ['device_id'], unique=False, ) op.create_index( 'ix_device_network_memberships_organization_id', 'device_network_memberships', ['organization_id'], unique=False, ) op.create_index( 'ix_device_network_memberships_portal_network_id', 'device_network_memberships', ['portal_network_id'], unique=False, ) op.create_index( 'ix_device_network_memberships_state', 'device_network_memberships', ['state'], unique=False, ) op.create_index( 'ix_device_network_memberships_user_id', 'device_network_memberships', ['user_id'], unique=False, ) op.create_index( 'ix_device_network_memberships_user_network_approval_id', 'device_network_memberships', ['user_network_approval_id'], unique=False, ) # ------------------------------------------------------------------ # Step 5: Migrate data back into device_network_memberships # ------------------------------------------------------------------ # Map network_access_requests rows back to device_network_memberships. # Reverse the status/active mapping using a best-effort approach. op.execute( """ INSERT INTO device_network_memberships ( id, organization_id, user_id, device_id, portal_network_id, user_network_approval_id, state, join_seen, currently_authorized, approved_for_activation, created_at, updated_at, deleted_at ) SELECT nar.id, nar.organization_id, nar.user_id, nar.device_id, nar.portal_network_id, una.id AS user_network_approval_id, CASE nar.status WHEN 'approved' THEN CASE WHEN nar.active = true THEN 'active_authorized' ELSE 'approved_inactive' END WHEN 'pending' THEN 'pending_request' ELSE nar.status END AS state, nar.join_seen, nar.active AS currently_authorized, CASE WHEN nar.status = 'approved' THEN true ELSE false END AS approved_for_activation, nar.created_at, nar.updated_at, nar.deleted_at FROM network_access_requests nar JOIN user_network_approvals una ON una.user_id = nar.user_id AND una.portal_network_id = nar.portal_network_id AND (una.deleted_at IS NOT DISTINCT FROM nar.deleted_at); """ ) # ------------------------------------------------------------------ # Step 6: Restore activation_sessions FK # ------------------------------------------------------------------ # 6a. Add the old column (nullable first so we can populate) op.add_column( 'activation_sessions', sa.Column('device_network_membership_id', sa.String(length=36), nullable=True), ) # 6b. Populate the old column from the new column before it disappears op.execute( """ UPDATE activation_sessions SET device_network_membership_id = network_access_request_id WHERE network_access_request_id IS NOT NULL; """ ) # 6c. Drop the new column, FK, and index op.drop_constraint( 'fk_activation_sessions_network_access_request', 'activation_sessions', type_='foreignkey', ) op.drop_index( 'ix_activation_sessions_network_access_request_id', table_name='activation_sessions', ) op.drop_column('activation_sessions', 'network_access_request_id') # 6d. Alter the old column to NOT NULL op.alter_column( 'activation_sessions', 'device_network_membership_id', nullable=False, ) # 6d. Recreate the old foreign key op.create_foreign_key( None, 'activation_sessions', 'device_network_memberships', ['device_network_membership_id'], ['id'], ) # 6e. Recreate the old index op.create_index( 'ix_activation_sessions_device_network_membership_id', 'activation_sessions', ['device_network_membership_id'], unique=False, ) # ------------------------------------------------------------------ # Step 7: Restore zerotier_memberships FK # ------------------------------------------------------------------ # 7a. Add the old column (nullable first so we can populate) op.add_column( 'zerotier_memberships', sa.Column('device_network_membership_id', sa.String(length=36), nullable=True), ) # 7b. Populate the old column from the new column before it disappears op.execute( """ UPDATE zerotier_memberships SET device_network_membership_id = network_access_request_id WHERE network_access_request_id IS NOT NULL; """ ) # 7c. Drop the new column, FK, and index op.drop_constraint( 'fk_zerotier_memberships_network_access_request', 'zerotier_memberships', type_='foreignkey', ) op.drop_index( 'ix_zerotier_memberships_network_access_request_id', table_name='zerotier_memberships', ) op.drop_column('zerotier_memberships', 'network_access_request_id') # 7d. Recreate the old foreign key op.create_foreign_key( None, 'zerotier_memberships', 'device_network_memberships', ['device_network_membership_id'], ['id'], ) # 7e. Recreate the old index op.create_index( 'ix_zerotier_memberships_device_network_membership_id', 'zerotier_memberships', ['device_network_membership_id'], unique=False, ) # ------------------------------------------------------------------ # Step 8: Drop the new network_access_requests table and indexes # ------------------------------------------------------------------ op.drop_index( 'ix_network_access_requests_user_id', table_name='network_access_requests', ) op.drop_index( 'ix_network_access_requests_status', table_name='network_access_requests', ) op.drop_index( 'ix_network_access_requests_portal_network_id', table_name='network_access_requests', ) op.drop_index( 'ix_network_access_requests_organization_id', table_name='network_access_requests', ) op.drop_index( 'ix_network_access_requests_device_id', table_name='network_access_requests', ) op.drop_table('network_access_requests')