Files
gatehouse-api/migrations/versions/merge_approval_membership_tables.py
T

708 lines
24 KiB
Python

"""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
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'c0a1b2c3d4e5'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# UPGRADE
# ---------------------------------------------------------------------------
def upgrade():
# ------------------------------------------------------------------
# Step 0: Ensure enum types exist (they may already exist from old tables)
# ------------------------------------------------------------------
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'approval_grant_type') THEN
CREATE TYPE approval_grant_type AS ENUM ('requested', 'assigned');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'approval_state') THEN
CREATE TYPE approval_state AS ENUM ('pending', 'approved', 'rejected', 'revoked', 'suspended');
END IF;
END$$;
""")
# ------------------------------------------------------------------
# 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',
postgresql.ENUM('requested', 'assigned', name='approval_grant_type', create_type=False),
nullable=False,
),
sa.Column(
'status',
postgresql.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',
postgresql.ENUM('requested', 'assigned', name='approval_grant_type', create_type=False),
nullable=False,
),
sa.Column(
'state',
postgresql.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',
postgresql.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')