refactor(db): consolidate migrations into single initial migration
Replace 35 incremental migration files with one comprehensive initial migration that captures the complete database schema. This simplifies the migration history and eliminates complex dependency chains between migrations. The new migration (6a4c4ed4a5c6) includes all tables for: - Users, organizations, and authentication - SSH CA and certificate management - ZeroTier network integration - OIDC/OAuth providers - Security policies and audit logging
This commit is contained in:
@@ -1,357 +0,0 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 0abed208e728
|
||||
Revises: None
|
||||
Create Date: 2026-01-11 16:07:05.491356
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('organizations',
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('slug', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('logo_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), 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.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True)
|
||||
op.create_table('users',
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('email_verified', sa.Boolean(), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('avatar_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING', name='userstatus'), nullable=False),
|
||||
sa.Column('last_login_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_login_ip', sa.String(length=45), 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.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_status'), 'users', ['status'], unique=False)
|
||||
op.create_table('audit_logs',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('action', sa.Enum('USER_LOGIN', 'USER_LOGOUT', 'USER_REGISTER', 'USER_UPDATE', 'USER_DELETE', 'PASSWORD_CHANGE', 'PASSWORD_RESET', 'ORG_CREATE', 'ORG_UPDATE', 'ORG_DELETE', 'ORG_MEMBER_ADD', 'ORG_MEMBER_REMOVE', 'ORG_MEMBER_ROLE_CHANGE', 'SESSION_CREATE', 'SESSION_REVOKE', 'AUTH_METHOD_ADD', 'AUTH_METHOD_REMOVE', name='auditaction'), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('resource_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('request_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('extra_data', sa.JSON(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('success', sa.Boolean(), nullable=False),
|
||||
sa.Column('error_message', 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(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index('idx_audit_org', 'audit_logs', ['organization_id', 'created_at'], unique=False)
|
||||
op.create_index('idx_audit_resource', 'audit_logs', ['resource_type', 'resource_id'], unique=False)
|
||||
op.create_index('idx_audit_user_action', 'audit_logs', ['user_id', 'action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_organization_id'), 'audit_logs', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_request_id'), 'audit_logs', ['request_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
|
||||
op.create_table('authentication_methods',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('method_type', sa.Enum('PASSWORD', 'GOOGLE', 'GITHUB', 'MICROSOFT', 'SAML', 'OIDC', name='authmethodtype'), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=True),
|
||||
sa.Column('provider_user_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('provider_data', sa.JSON(), nullable=True),
|
||||
sa.Column('is_primary', sa.Boolean(), nullable=False),
|
||||
sa.Column('verified', sa.Boolean(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), 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(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'method_type', 'provider_user_id', name='uix_user_method_provider')
|
||||
)
|
||||
op.create_index('idx_user_method', 'authentication_methods', ['user_id', 'method_type'], unique=False)
|
||||
op.create_index(op.f('ix_authentication_methods_method_type'), 'authentication_methods', ['method_type'], unique=False)
|
||||
op.create_index(op.f('ix_authentication_methods_user_id'), 'authentication_methods', ['user_id'], unique=False)
|
||||
op.create_table('oidc_clients',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('client_secret_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('redirect_uris', sa.JSON(), nullable=False),
|
||||
sa.Column('grant_types', sa.JSON(), nullable=False),
|
||||
sa.Column('response_types', sa.JSON(), nullable=False),
|
||||
sa.Column('scopes', sa.JSON(), nullable=False),
|
||||
sa.Column('logo_uri', sa.String(length=512), nullable=True),
|
||||
sa.Column('client_uri', sa.String(length=512), nullable=True),
|
||||
sa.Column('policy_uri', sa.String(length=512), nullable=True),
|
||||
sa.Column('tos_uri', sa.String(length=512), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_confidential', sa.Boolean(), nullable=False),
|
||||
sa.Column('require_pkce', sa.Boolean(), nullable=False),
|
||||
sa.Column('access_token_lifetime', sa.Integer(), nullable=False),
|
||||
sa.Column('refresh_token_lifetime', sa.Integer(), nullable=False),
|
||||
sa.Column('id_token_lifetime', sa.Integer(), 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oidc_clients_client_id'), 'oidc_clients', ['client_id'], unique=True)
|
||||
op.create_index(op.f('ix_oidc_clients_organization_id'), 'oidc_clients', ['organization_id'], unique=False)
|
||||
op.create_table('organization_members',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'MEMBER', 'GUEST', name='organizationrole'), nullable=False),
|
||||
sa.Column('invited_by_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('invited_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('joined_at', sa.DateTime(), 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(['invited_by_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org')
|
||||
)
|
||||
op.create_index(op.f('ix_organization_members_organization_id'), 'organization_members', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_organization_members_user_id'), 'organization_members', ['user_id'], unique=False)
|
||||
op.create_table('sessions',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('token', sa.String(length=255), nullable=False),
|
||||
sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', 'REVOKED', name='sessionstatus'), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('device_info', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_activity_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked_reason', sa.String(length=255), 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(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sessions_token'), 'sessions', ['token'], unique=True)
|
||||
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
||||
op.create_table('oidc_audit_logs',
|
||||
sa.Column('event_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('success', sa.Boolean(), nullable=False),
|
||||
sa.Column('error_code', sa.String(length=100), nullable=True),
|
||||
sa.Column('error_description', sa.Text(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('request_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('event_metadata', sa.JSON(), 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(['client_id'], ['oidc_clients.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oidc_audit_logs_client_id'), 'oidc_audit_logs', ['client_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_audit_logs_event_type'), 'oidc_audit_logs', ['event_type'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_audit_logs_ip_address'), 'oidc_audit_logs', ['ip_address'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_audit_logs_request_id'), 'oidc_audit_logs', ['request_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_audit_logs_success'), 'oidc_audit_logs', ['success'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_audit_logs_user_id'), 'oidc_audit_logs', ['user_id'], unique=False)
|
||||
op.create_table('oidc_authorization_codes',
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('code_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('redirect_uri', sa.String(length=512), nullable=False),
|
||||
sa.Column('scope', sa.JSON(), nullable=True),
|
||||
sa.Column('nonce', sa.String(length=255), nullable=True),
|
||||
sa.Column('code_verifier', sa.String(length=255), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_used', sa.Boolean(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', 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(['client_id'], ['oidc_clients.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oidc_authorization_codes_client_id'), 'oidc_authorization_codes', ['client_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_authorization_codes_expires_at'), 'oidc_authorization_codes', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_authorization_codes_user_id'), 'oidc_authorization_codes', ['user_id'], unique=False)
|
||||
op.create_table('oidc_refresh_tokens',
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('access_token_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('scope', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked_reason', sa.String(length=255), nullable=True),
|
||||
sa.Column('previous_token_hash', sa.String(length=255), nullable=True),
|
||||
sa.Column('rotation_count', sa.Integer(), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', 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(['access_token_id'], ['sessions.id'], ),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oidc_refresh_tokens_access_token_id'), 'oidc_refresh_tokens', ['access_token_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_refresh_tokens_client_id'), 'oidc_refresh_tokens', ['client_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_refresh_tokens_expires_at'), 'oidc_refresh_tokens', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_refresh_tokens_token_hash'), 'oidc_refresh_tokens', ['token_hash'], unique=True)
|
||||
op.create_index(op.f('ix_oidc_refresh_tokens_user_id'), 'oidc_refresh_tokens', ['user_id'], unique=False)
|
||||
op.create_table('oidc_sessions',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('state', sa.String(length=255), nullable=False),
|
||||
sa.Column('nonce', sa.String(length=255), nullable=True),
|
||||
sa.Column('redirect_uri', sa.String(length=512), nullable=False),
|
||||
sa.Column('scope', sa.JSON(), nullable=True),
|
||||
sa.Column('code_challenge', sa.String(length=255), nullable=True),
|
||||
sa.Column('code_challenge_method', sa.String(length=10), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('authenticated_at', sa.DateTime(), 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(['client_id'], ['oidc_clients.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oidc_sessions_client_id'), 'oidc_sessions', ['client_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_sessions_expires_at'), 'oidc_sessions', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_sessions_state'), 'oidc_sessions', ['state'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_sessions_user_id'), 'oidc_sessions', ['user_id'], unique=False)
|
||||
op.create_table('oidc_token_metadata',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('token_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('token_jti', sa.String(length=255), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked_reason', sa.String(length=255), nullable=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.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oidc_token_metadata_client_id'), 'oidc_token_metadata', ['client_id'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_token_metadata_expires_at'), 'oidc_token_metadata', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_token_metadata_token_jti'), 'oidc_token_metadata', ['token_jti'], unique=False)
|
||||
op.create_index(op.f('ix_oidc_token_metadata_user_id'), 'oidc_token_metadata', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_oidc_token_metadata_user_id'), table_name='oidc_token_metadata')
|
||||
op.drop_index(op.f('ix_oidc_token_metadata_token_jti'), table_name='oidc_token_metadata')
|
||||
op.drop_index(op.f('ix_oidc_token_metadata_expires_at'), table_name='oidc_token_metadata')
|
||||
op.drop_index(op.f('ix_oidc_token_metadata_client_id'), table_name='oidc_token_metadata')
|
||||
op.drop_table('oidc_token_metadata')
|
||||
op.drop_index(op.f('ix_oidc_sessions_user_id'), table_name='oidc_sessions')
|
||||
op.drop_index(op.f('ix_oidc_sessions_state'), table_name='oidc_sessions')
|
||||
op.drop_index(op.f('ix_oidc_sessions_expires_at'), table_name='oidc_sessions')
|
||||
op.drop_index(op.f('ix_oidc_sessions_client_id'), table_name='oidc_sessions')
|
||||
op.drop_table('oidc_sessions')
|
||||
op.drop_index(op.f('ix_oidc_refresh_tokens_user_id'), table_name='oidc_refresh_tokens')
|
||||
op.drop_index(op.f('ix_oidc_refresh_tokens_token_hash'), table_name='oidc_refresh_tokens')
|
||||
op.drop_index(op.f('ix_oidc_refresh_tokens_expires_at'), table_name='oidc_refresh_tokens')
|
||||
op.drop_index(op.f('ix_oidc_refresh_tokens_client_id'), table_name='oidc_refresh_tokens')
|
||||
op.drop_index(op.f('ix_oidc_refresh_tokens_access_token_id'), table_name='oidc_refresh_tokens')
|
||||
op.drop_table('oidc_refresh_tokens')
|
||||
op.drop_index(op.f('ix_oidc_authorization_codes_user_id'), table_name='oidc_authorization_codes')
|
||||
op.drop_index(op.f('ix_oidc_authorization_codes_expires_at'), table_name='oidc_authorization_codes')
|
||||
op.drop_index(op.f('ix_oidc_authorization_codes_client_id'), table_name='oidc_authorization_codes')
|
||||
op.drop_table('oidc_authorization_codes')
|
||||
op.drop_index(op.f('ix_oidc_audit_logs_user_id'), table_name='oidc_audit_logs')
|
||||
op.drop_index(op.f('ix_oidc_audit_logs_success'), table_name='oidc_audit_logs')
|
||||
op.drop_index(op.f('ix_oidc_audit_logs_request_id'), table_name='oidc_audit_logs')
|
||||
op.drop_index(op.f('ix_oidc_audit_logs_ip_address'), table_name='oidc_audit_logs')
|
||||
op.drop_index(op.f('ix_oidc_audit_logs_event_type'), table_name='oidc_audit_logs')
|
||||
op.drop_index(op.f('ix_oidc_audit_logs_client_id'), table_name='oidc_audit_logs')
|
||||
op.drop_table('oidc_audit_logs')
|
||||
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
||||
op.drop_index(op.f('ix_sessions_token'), table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
op.drop_index(op.f('ix_organization_members_user_id'), table_name='organization_members')
|
||||
op.drop_index(op.f('ix_organization_members_organization_id'), table_name='organization_members')
|
||||
op.drop_table('organization_members')
|
||||
op.drop_index(op.f('ix_oidc_clients_organization_id'), table_name='oidc_clients')
|
||||
op.drop_index(op.f('ix_oidc_clients_client_id'), table_name='oidc_clients')
|
||||
op.drop_table('oidc_clients')
|
||||
op.drop_index(op.f('ix_authentication_methods_user_id'), table_name='authentication_methods')
|
||||
op.drop_index(op.f('ix_authentication_methods_method_type'), table_name='authentication_methods')
|
||||
op.drop_index('idx_user_method', table_name='authentication_methods')
|
||||
op.drop_table('authentication_methods')
|
||||
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_request_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_organization_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
|
||||
op.drop_index('idx_audit_user_action', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_resource', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_org', table_name='audit_logs')
|
||||
op.drop_table('audit_logs')
|
||||
op.drop_index(op.f('ix_users_status'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_organizations_slug'), table_name='organizations')
|
||||
op.drop_table('organizations')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Database migration: Add TOTP support to authentication_methods table.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 0abed208e728
|
||||
Create Date: 2026-01-11 00:00:00
|
||||
|
||||
This migration adds TOTP (Time-based One-Time Password) support to the
|
||||
authentication_methods table by adding three new columns:
|
||||
- totp_secret: Stores the TOTP secret key
|
||||
- totp_backup_codes: Stores backup codes for account recovery
|
||||
- totp_verified_at: Tracks when TOTP was verified
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# Revision identifiers
|
||||
revision = '002'
|
||||
down_revision = '001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add TOTP columns to authentication_methods table."""
|
||||
|
||||
# Add TOTP secret column
|
||||
op.add_column(
|
||||
'authentication_methods',
|
||||
sa.Column('totp_secret', sa.String(32), nullable=True)
|
||||
)
|
||||
|
||||
# Add TOTP backup codes column (JSON type for PostgreSQL)
|
||||
op.add_column(
|
||||
'authentication_methods',
|
||||
sa.Column('totp_backup_codes', postgresql.JSON, nullable=True)
|
||||
)
|
||||
|
||||
# Add TOTP verified at column
|
||||
op.add_column(
|
||||
'authentication_methods',
|
||||
sa.Column('totp_verified_at', sa.DateTime, nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove TOTP columns from authentication_methods table."""
|
||||
|
||||
# Remove TOTP columns in reverse order of addition
|
||||
op.drop_column('authentication_methods', 'totp_verified_at')
|
||||
op.drop_column('authentication_methods', 'totp_backup_codes')
|
||||
op.drop_column('authentication_methods', 'totp_secret')
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Database migration: Create oidc_jwks_keys table.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2024-01-01 00:00:00
|
||||
|
||||
This migration creates the oidc_jwks_keys table for persisting OIDC signing keys.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# Revision identifiers
|
||||
revision = '003'
|
||||
down_revision = '002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create oidc_jwks_keys table."""
|
||||
|
||||
op.create_table(
|
||||
'oidc_jwks_keys',
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime, nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime, nullable=True),
|
||||
sa.Column('kid', sa.String(255), nullable=False),
|
||||
sa.Column('key_type', sa.String(50), nullable=False),
|
||||
sa.Column('private_key', sa.Text, nullable=False),
|
||||
sa.Column('public_key', sa.Text, nullable=False),
|
||||
sa.Column('algorithm', sa.String(50), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean, default=True, nullable=False),
|
||||
sa.Column('is_primary', sa.Boolean, default=False, nullable=False),
|
||||
)
|
||||
|
||||
# Create unique index on kid
|
||||
op.create_index('ix_oidc_jwks_keys_kid', 'oidc_jwks_keys', ['kid'], unique=True)
|
||||
|
||||
# Create index on is_active for filtering active keys
|
||||
op.create_index('ix_oidc_jwks_keys_is_active', 'oidc_jwks_keys', ['is_active'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop oidc_jwks_keys table."""
|
||||
op.drop_index('ix_oidc_jwks_keys_is_active', table_name='oidc_jwks_keys')
|
||||
op.drop_index('ix_oidc_jwks_keys_kid', table_name='oidc_jwks_keys')
|
||||
op.drop_table('oidc_jwks_keys')
|
||||
@@ -1,122 +0,0 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 5d99e6d4cdc6
|
||||
Revises: 003
|
||||
Create Date: 2026-01-16 15:31:36.288933
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '004'
|
||||
down_revision = '003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('mfa_policy_compliance',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('status', sa.Enum('NOT_APPLICABLE', 'PENDING', 'IN_GRACE', 'COMPLIANT', 'PAST_DUE', 'SUSPENDED', name='mfacompliancestatus'), nullable=False),
|
||||
sa.Column('policy_version', sa.Integer(), nullable=False),
|
||||
sa.Column('applied_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('deadline_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('compliant_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('suspended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_notified_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('notification_count', sa.Integer(), 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org_compliance')
|
||||
)
|
||||
op.create_index(op.f('ix_mfa_policy_compliance_organization_id'), 'mfa_policy_compliance', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_mfa_policy_compliance_user_id'), 'mfa_policy_compliance', ['user_id'], unique=False)
|
||||
op.create_table('organization_security_policies',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('mfa_policy_mode', sa.Enum('DISABLED', 'OPTIONAL', 'REQUIRE_TOTP', 'REQUIRE_WEBAUTHN', 'REQUIRE_TOTP_OR_WEBAUTHN', name='mfapolicymode'), nullable=False),
|
||||
sa.Column('mfa_grace_period_days', sa.Integer(), nullable=False),
|
||||
sa.Column('notify_days_before', sa.Integer(), nullable=False),
|
||||
sa.Column('policy_version', sa.Integer(), nullable=False),
|
||||
sa.Column('updated_by_user_id', sa.String(length=36), 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_organization_security_policies_organization_id'), 'organization_security_policies', ['organization_id'], unique=True)
|
||||
op.create_table('user_security_policies',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('mfa_override_mode', sa.Enum('INHERIT', 'REQUIRED', 'EXEMPT', name='mfarequirementoverride'), nullable=False),
|
||||
sa.Column('force_totp', sa.Boolean(), nullable=False),
|
||||
sa.Column('force_webauthn', 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org_policy')
|
||||
)
|
||||
op.create_index(op.f('ix_user_security_policies_organization_id'), 'user_security_policies', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_user_security_policies_user_id'), 'user_security_policies', ['user_id'], unique=False)
|
||||
|
||||
# Use batch operations for SQLite-compatible column type changes
|
||||
with op.batch_alter_table('audit_logs', schema=None) as batch_op:
|
||||
batch_op.alter_column('action',
|
||||
existing_type=sa.VARCHAR(length=22),
|
||||
type_=sa.Enum('USER_LOGIN', 'USER_LOGOUT', 'USER_REGISTER', 'USER_UPDATE', 'USER_DELETE', 'PASSWORD_CHANGE', 'PASSWORD_RESET', 'ORG_CREATE', 'ORG_UPDATE', 'ORG_DELETE', 'ORG_MEMBER_ADD', 'ORG_MEMBER_REMOVE', 'ORG_MEMBER_ROLE_CHANGE', 'SESSION_CREATE', 'SESSION_REVOKE', 'AUTH_METHOD_ADD', 'AUTH_METHOD_REMOVE', 'TOTP_ENROLL_INITIATED', 'TOTP_ENROLL_COMPLETED', 'TOTP_VERIFY_SUCCESS', 'TOTP_VERIFY_FAILED', 'TOTP_DISABLED', 'TOTP_BACKUP_CODE_USED', 'TOTP_BACKUP_CODES_REGENERATED', 'WEBAUTHN_REGISTER_INITIATED', 'WEBAUTHN_REGISTER_COMPLETED', 'WEBAUTHN_REGISTER_FAILED', 'WEBAUTHN_LOGIN_INITIATED', 'WEBAUTHN_LOGIN_SUCCESS', 'WEBAUTHN_LOGIN_FAILED', 'WEBAUTHN_CREDENTIAL_DELETED', 'WEBAUTHN_CREDENTIAL_RENAMED', 'ORG_SECURITY_POLICY_UPDATE', 'USER_SECURITY_POLICY_OVERRIDE_UPDATE', 'MFA_POLICY_USER_SUSPENDED', 'MFA_POLICY_USER_COMPLIANT', name='auditaction'),
|
||||
existing_nullable=False)
|
||||
|
||||
op.drop_index(op.f('ix_oidc_jwks_keys_is_active'), table_name='oidc_jwks_keys')
|
||||
op.add_column('sessions', sa.Column('is_compliance_only', sa.Boolean(), nullable=False))
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.alter_column('status',
|
||||
existing_type=sa.VARCHAR(length=9),
|
||||
type_=sa.Enum('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING', 'COMPLIANCE_SUSPENDED', name='userstatus'),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.alter_column('status',
|
||||
existing_type=sa.Enum('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING', 'COMPLIANCE_SUSPENDED', name='userstatus'),
|
||||
type_=sa.VARCHAR(length=9),
|
||||
existing_nullable=False)
|
||||
op.drop_column('sessions', 'is_compliance_only')
|
||||
op.create_index(op.f('ix_oidc_jwks_keys_is_active'), 'oidc_jwks_keys', ['is_active'], unique=False)
|
||||
|
||||
with op.batch_alter_table('audit_logs', schema=None) as batch_op:
|
||||
batch_op.alter_column('action',
|
||||
existing_type=sa.Enum('USER_LOGIN', 'USER_LOGOUT', 'USER_REGISTER', 'USER_UPDATE', 'USER_DELETE', 'PASSWORD_CHANGE', 'PASSWORD_RESET', 'ORG_CREATE', 'ORG_UPDATE', 'ORG_DELETE', 'ORG_MEMBER_ADD', 'ORG_MEMBER_REMOVE', 'ORG_MEMBER_ROLE_CHANGE', 'SESSION_CREATE', 'SESSION_REVOKE', 'AUTH_METHOD_ADD', 'AUTH_METHOD_REMOVE', 'TOTP_ENROLL_INITIATED', 'TOTP_ENROLL_COMPLETED', 'TOTP_VERIFY_SUCCESS', 'TOTP_VERIFY_FAILED', 'TOTP_DISABLED', 'TOTP_BACKUP_CODE_USED', 'TOTP_BACKUP_CODES_REGENERATED', 'WEBAUTHN_REGISTER_INITIATED', 'WEBAUTHN_REGISTER_COMPLETED', 'WEBAUTHN_REGISTER_FAILED', 'WEBAUTHN_LOGIN_INITIATED', 'WEBAUTHN_LOGIN_SUCCESS', 'WEBAUTHN_LOGIN_FAILED', 'WEBAUTHN_CREDENTIAL_DELETED', 'WEBAUTHN_CREDENTIAL_RENAMED', 'ORG_SECURITY_POLICY_UPDATE', 'USER_SECURITY_POLICY_OVERRIDE_UPDATE', 'MFA_POLICY_USER_SUSPENDED', 'MFA_POLICY_USER_COMPLIANT', name='auditaction'),
|
||||
type_=sa.VARCHAR(length=22),
|
||||
existing_nullable=False)
|
||||
|
||||
op.drop_index(op.f('ix_user_security_policies_user_id'), table_name='user_security_policies')
|
||||
op.drop_index(op.f('ix_user_security_policies_organization_id'), table_name='user_security_policies')
|
||||
op.drop_table('user_security_policies')
|
||||
op.drop_index(op.f('ix_organization_security_policies_organization_id'), table_name='organization_security_policies')
|
||||
op.drop_table('organization_security_policies')
|
||||
op.drop_index(op.f('ix_mfa_policy_compliance_user_id'), table_name='mfa_policy_compliance')
|
||||
op.drop_index(op.f('ix_mfa_policy_compliance_organization_id'), table_name='mfa_policy_compliance')
|
||||
op.drop_table('mfa_policy_compliance')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Fix oidc_refresh_tokens.access_token_id — widen column and drop wrong FK
|
||||
|
||||
The access_token_id column was VARCHAR(36) with a foreign key to sessions.id.
|
||||
In practice the code stores JWT JTI strings (43+ chars) in this column, not
|
||||
session UUIDs, so the FK constraint was wrong and the column was too narrow.
|
||||
|
||||
This migration:
|
||||
1. Drops the foreign key constraint to sessions.id (IF EXISTS — may have been
|
||||
applied manually already via raw SQL)
|
||||
2. Widens the column to VARCHAR(255)
|
||||
|
||||
Revision ID: 005
|
||||
Revises: d2fd4f159054
|
||||
Create Date: 2026-02-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '005'
|
||||
down_revision = 'd2fd4f159054'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _fk_exists(conn, table_name, constraint_name):
|
||||
"""Check whether a named FK constraint exists on a table."""
|
||||
insp = Inspector.from_engine(conn)
|
||||
fks = insp.get_foreign_keys(table_name)
|
||||
return any(fk.get('name') == constraint_name for fk in fks)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop the incorrect FK to sessions.id only if it still exists
|
||||
# (may have been removed manually before this migration was written)
|
||||
if _fk_exists(conn, 'oidc_refresh_tokens', 'oidc_refresh_tokens_access_token_id_fkey'):
|
||||
op.drop_constraint(
|
||||
'oidc_refresh_tokens_access_token_id_fkey',
|
||||
'oidc_refresh_tokens',
|
||||
type_='foreignkey'
|
||||
)
|
||||
|
||||
# Widen the column to hold JWT JTI strings (43+ chars)
|
||||
op.alter_column(
|
||||
'oidc_refresh_tokens',
|
||||
'access_token_id',
|
||||
existing_type=sa.String(length=36),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.alter_column(
|
||||
'oidc_refresh_tokens',
|
||||
'access_token_id',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.String(length=36),
|
||||
existing_nullable=True
|
||||
)
|
||||
# Re-add the FK constraint to sessions.id
|
||||
op.create_foreign_key(
|
||||
'oidc_refresh_tokens_access_token_id_fkey',
|
||||
'oidc_refresh_tokens',
|
||||
'sessions',
|
||||
['access_token_id'],
|
||||
['id']
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Add Department and Principal models for SSH CA management.
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-02-27 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '006'
|
||||
down_revision = '005'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### Department table ###
|
||||
op.create_table('departments',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('organization_id', 'name', name='uix_org_dept_name')
|
||||
)
|
||||
op.create_index(op.f('ix_departments_organization_id'), 'departments', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False)
|
||||
|
||||
# ### DepartmentMembership table ###
|
||||
op.create_table('department_memberships',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('department_id', sa.String(length=36), 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(['department_id'], ['departments.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'department_id', name='uix_user_dept')
|
||||
)
|
||||
op.create_index(op.f('ix_department_memberships_user_id'), 'department_memberships', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_department_memberships_department_id'), 'department_memberships', ['department_id'], unique=False)
|
||||
|
||||
# ### Principal table ###
|
||||
op.create_table('principals',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('organization_id', 'name', name='uix_org_principal_name')
|
||||
)
|
||||
op.create_index(op.f('ix_principals_organization_id'), 'principals', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_principals_name'), 'principals', ['name'], unique=False)
|
||||
|
||||
# ### PrincipalMembership table ###
|
||||
op.create_table('principal_memberships',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('principal_id', sa.String(length=36), 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(['principal_id'], ['principals.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'principal_id', name='uix_user_principal')
|
||||
)
|
||||
op.create_index(op.f('ix_principal_memberships_user_id'), 'principal_memberships', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_principal_memberships_principal_id'), 'principal_memberships', ['principal_id'], unique=False)
|
||||
|
||||
# ### DepartmentPrincipal table ###
|
||||
op.create_table('department_principals',
|
||||
sa.Column('department_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('principal_id', sa.String(length=36), 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(['department_id'], ['departments.id'], ),
|
||||
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('department_id', 'principal_id', name='uix_dept_principal')
|
||||
)
|
||||
op.create_index(op.f('ix_department_principals_department_id'), 'department_principals', ['department_id'], unique=False)
|
||||
op.create_index(op.f('ix_department_principals_principal_id'), 'department_principals', ['principal_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_department_principals_principal_id'), table_name='department_principals')
|
||||
op.drop_index(op.f('ix_department_principals_department_id'), table_name='department_principals')
|
||||
op.drop_table('department_principals')
|
||||
|
||||
op.drop_index(op.f('ix_principal_memberships_principal_id'), table_name='principal_memberships')
|
||||
op.drop_index(op.f('ix_principal_memberships_user_id'), table_name='principal_memberships')
|
||||
op.drop_table('principal_memberships')
|
||||
|
||||
op.drop_index(op.f('ix_principals_name'), table_name='principals')
|
||||
op.drop_index(op.f('ix_principals_organization_id'), table_name='principals')
|
||||
op.drop_table('principals')
|
||||
|
||||
op.drop_index(op.f('ix_department_memberships_department_id'), table_name='department_memberships')
|
||||
op.drop_index(op.f('ix_department_memberships_user_id'), table_name='department_memberships')
|
||||
op.drop_table('department_memberships')
|
||||
|
||||
op.drop_index(op.f('ix_departments_name'), table_name='departments')
|
||||
op.drop_index(op.f('ix_departments_organization_id'), table_name='departments')
|
||||
op.drop_table('departments')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Add SSH CA models: SSHKey, SSHCertificate, CA, CertificateAuditLog.
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-02-27 11:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '007'
|
||||
down_revision = '006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### CA table ###
|
||||
op.create_table('cas',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('key_type', sa.Enum('ed25519', 'rsa', 'ecdsa', name='ca_key_type_enum'), nullable=False),
|
||||
sa.Column('private_key', sa.Text(), nullable=False),
|
||||
sa.Column('public_key', sa.Text(), nullable=False),
|
||||
sa.Column('fingerprint', sa.String(length=255), nullable=False),
|
||||
sa.Column('crl_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('crl_endpoint', sa.String(length=512), nullable=True),
|
||||
sa.Column('default_cert_validity_hours', sa.Integer(), nullable=False),
|
||||
sa.Column('max_cert_validity_hours', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('rotated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('rotation_reason', sa.String(length=255), 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('fingerprint'),
|
||||
sa.UniqueConstraint('organization_id', 'name', name='uix_org_ca_name')
|
||||
)
|
||||
op.create_index(op.f('ix_cas_organization_id'), 'cas', ['organization_id'], unique=False)
|
||||
op.create_index('idx_ca_org_active', 'cas', ['organization_id', 'is_active'], unique=False)
|
||||
|
||||
# ### SSHKey table ###
|
||||
op.create_table('ssh_keys',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('payload', sa.Text(), nullable=False),
|
||||
sa.Column('fingerprint', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('verified', sa.Boolean(), nullable=False),
|
||||
sa.Column('verified_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('verify_text', sa.String(length=255), nullable=True),
|
||||
sa.Column('verify_text_created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('key_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('key_bits', sa.Integer(), nullable=True),
|
||||
sa.Column('key_comment', sa.String(length=255), 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(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('payload'),
|
||||
sa.UniqueConstraint('fingerprint')
|
||||
)
|
||||
op.create_index(op.f('ix_ssh_keys_user_id'), 'ssh_keys', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_keys_fingerprint'), 'ssh_keys', ['fingerprint'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_keys_verified'), 'ssh_keys', ['verified'], unique=False)
|
||||
op.create_index('idx_ssh_key_user_verified', 'ssh_keys', ['user_id', 'verified'], unique=False)
|
||||
|
||||
# ### SSHCertificate table ###
|
||||
op.create_table('ssh_certificates',
|
||||
sa.Column('ca_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('ssh_key_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('certificate', sa.Text(), nullable=False),
|
||||
sa.Column('serial', sa.String(length=255), nullable=False),
|
||||
sa.Column('key_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('cert_type', sa.Enum('user', 'host', name='ssh_cert_type_enum'), nullable=False),
|
||||
sa.Column('principals', sa.JSON(), nullable=False),
|
||||
sa.Column('valid_after', sa.DateTime(), nullable=False),
|
||||
sa.Column('valid_before', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoke_reason', sa.String(length=255), nullable=True),
|
||||
sa.Column('status', sa.Enum('requested', 'issued', 'revoked', 'expired', 'superseded', name='ssh_cert_status_enum'), nullable=False),
|
||||
sa.Column('request_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('request_user_agent', sa.String(length=512), nullable=True),
|
||||
sa.Column('critical_options', sa.JSON(), nullable=True),
|
||||
sa.Column('extensions', sa.JSON(), 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(['ca_id'], ['cas.id'], ),
|
||||
sa.ForeignKeyConstraint(['ssh_key_id'], ['ssh_keys.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('serial')
|
||||
)
|
||||
op.create_index(op.f('ix_ssh_certificates_ca_id'), 'ssh_certificates', ['ca_id'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_certificates_user_id'), 'ssh_certificates', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_certificates_ssh_key_id'), 'ssh_certificates', ['ssh_key_id'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_certificates_serial'), 'ssh_certificates', ['serial'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_certificates_revoked'), 'ssh_certificates', ['revoked'], unique=False)
|
||||
op.create_index(op.f('ix_ssh_certificates_status'), 'ssh_certificates', ['status'], unique=False)
|
||||
op.create_index('idx_cert_user_status', 'ssh_certificates', ['user_id', 'status'], unique=False)
|
||||
op.create_index('idx_cert_validity', 'ssh_certificates', ['valid_after', 'valid_before'], unique=False)
|
||||
op.create_index('idx_cert_revoked', 'ssh_certificates', ['revoked', 'revoked_at'], unique=False)
|
||||
|
||||
# ### CertificateAuditLog table ###
|
||||
op.create_table('certificate_audit_logs',
|
||||
sa.Column('certificate_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('action', sa.String(length=50), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.String(length=512), nullable=True),
|
||||
sa.Column('request_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.Column('extra_data', sa.JSON(), nullable=True),
|
||||
sa.Column('success', sa.Boolean(), nullable=False),
|
||||
sa.Column('error_message', 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(['certificate_id'], ['ssh_certificates.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_certificate_audit_logs_certificate_id'), 'certificate_audit_logs', ['certificate_id'], unique=False)
|
||||
op.create_index(op.f('ix_certificate_audit_logs_user_id'), 'certificate_audit_logs', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_certificate_audit_logs_action'), 'certificate_audit_logs', ['action'], unique=False)
|
||||
op.create_index('idx_cert_audit_cert_action', 'certificate_audit_logs', ['certificate_id', 'action'], unique=False)
|
||||
op.create_index('idx_cert_audit_user', 'certificate_audit_logs', ['user_id', 'created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_cert_audit_user', table_name='certificate_audit_logs')
|
||||
op.drop_index('idx_cert_audit_cert_action', table_name='certificate_audit_logs')
|
||||
op.drop_index(op.f('ix_certificate_audit_logs_action'), table_name='certificate_audit_logs')
|
||||
op.drop_index(op.f('ix_certificate_audit_logs_user_id'), table_name='certificate_audit_logs')
|
||||
op.drop_index(op.f('ix_certificate_audit_logs_certificate_id'), table_name='certificate_audit_logs')
|
||||
op.drop_table('certificate_audit_logs')
|
||||
|
||||
op.drop_index('idx_cert_revoked', table_name='ssh_certificates')
|
||||
op.drop_index('idx_cert_validity', table_name='ssh_certificates')
|
||||
op.drop_index('idx_cert_user_status', table_name='ssh_certificates')
|
||||
op.drop_index(op.f('ix_ssh_certificates_status'), table_name='ssh_certificates')
|
||||
op.drop_index(op.f('ix_ssh_certificates_revoked'), table_name='ssh_certificates')
|
||||
op.drop_index(op.f('ix_ssh_certificates_serial'), table_name='ssh_certificates')
|
||||
op.drop_index(op.f('ix_ssh_certificates_ssh_key_id'), table_name='ssh_certificates')
|
||||
op.drop_index(op.f('ix_ssh_certificates_user_id'), table_name='ssh_certificates')
|
||||
op.drop_index(op.f('ix_ssh_certificates_ca_id'), table_name='ssh_certificates')
|
||||
op.drop_table('ssh_certificates')
|
||||
|
||||
op.drop_index('idx_ssh_key_user_verified', table_name='ssh_keys')
|
||||
op.drop_index(op.f('ix_ssh_keys_verified'), table_name='ssh_keys')
|
||||
op.drop_index(op.f('ix_ssh_keys_fingerprint'), table_name='ssh_keys')
|
||||
op.drop_index(op.f('ix_ssh_keys_user_id'), table_name='ssh_keys')
|
||||
op.drop_table('ssh_keys')
|
||||
|
||||
op.drop_index('idx_ca_org_active', table_name='cas')
|
||||
op.drop_index(op.f('ix_cas_organization_id'), table_name='cas')
|
||||
op.drop_table('cas')
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Add TOTP and WEBAUTHN to authmethodtype enum.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-02-27 15:00:00.000000
|
||||
|
||||
The original migration (001_base) created authmethodtype with only:
|
||||
PASSWORD, GOOGLE, GITHUB, MICROSOFT, SAML, OIDC
|
||||
|
||||
This migration adds the missing TOTP and WEBAUTHN values so
|
||||
has_totp_enabled() and has_webauthn_enabled() queries work correctly.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '008'
|
||||
down_revision = '007'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add TOTP to the enum (idempotent approach using DO block)
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = 'TOTP'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'authmethodtype')
|
||||
) THEN
|
||||
ALTER TYPE authmethodtype ADD VALUE 'TOTP';
|
||||
END IF;
|
||||
END$$;
|
||||
""")
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = 'WEBAUTHN'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'authmethodtype')
|
||||
) THEN
|
||||
ALTER TYPE authmethodtype ADD VALUE 'WEBAUTHN';
|
||||
END IF;
|
||||
END$$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# PostgreSQL does not support removing enum values; downgrade is a no-op.
|
||||
pass
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Sync auditaction enum with all AuditAction Python enum values.
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-02-27 15:20:00.000000
|
||||
|
||||
The auditaction DB enum was only created with the initial 17 values from 001_base.py.
|
||||
All TOTP, WebAuthn, OAuth, SSH, CA, Principal, and Department audit actions were added
|
||||
to the Python enum but never synced to the DB type.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '009'
|
||||
down_revision = '008'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
MISSING_VALUES = [
|
||||
'TOTP_ENROLL_INITIATED', 'TOTP_ENROLL_COMPLETED', 'TOTP_VERIFY_SUCCESS',
|
||||
'TOTP_VERIFY_FAILED', 'TOTP_DISABLED', 'TOTP_BACKUP_CODE_USED',
|
||||
'TOTP_BACKUP_CODES_REGENERATED', 'WEBAUTHN_REGISTER_INITIATED',
|
||||
'WEBAUTHN_REGISTER_COMPLETED', 'WEBAUTHN_REGISTER_FAILED',
|
||||
'WEBAUTHN_LOGIN_INITIATED', 'WEBAUTHN_LOGIN_SUCCESS', 'WEBAUTHN_LOGIN_FAILED',
|
||||
'WEBAUTHN_CREDENTIAL_DELETED', 'WEBAUTHN_CREDENTIAL_RENAMED',
|
||||
'ORG_SECURITY_POLICY_UPDATE', 'USER_SECURITY_POLICY_OVERRIDE_UPDATE',
|
||||
'MFA_POLICY_USER_SUSPENDED', 'MFA_POLICY_USER_COMPLIANT',
|
||||
'EXTERNAL_AUTH_LINK_INITIATED', 'EXTERNAL_AUTH_LINK_COMPLETED',
|
||||
'EXTERNAL_AUTH_LINK_FAILED', 'EXTERNAL_AUTH_UNLINK', 'EXTERNAL_AUTH_LOGIN',
|
||||
'EXTERNAL_AUTH_LOGIN_FAILED', 'EXTERNAL_AUTH_TOKEN_REFRESH',
|
||||
'EXTERNAL_AUTH_CONFIG_CREATE', 'EXTERNAL_AUTH_CONFIG_UPDATE',
|
||||
'EXTERNAL_AUTH_CONFIG_DELETE', 'SSH_KEY_ADDED', 'SSH_KEY_VERIFIED',
|
||||
'SSH_KEY_DELETED', 'SSH_KEY_VALIDATION_FAILED', 'SSH_CERT_REQUESTED',
|
||||
'SSH_CERT_ISSUED', 'SSH_CERT_FAILED', 'SSH_CERT_REVOKED', 'SSH_CERT_EXPIRED',
|
||||
'CA_CREATED', 'CA_UPDATED', 'CA_DELETED', 'CA_KEY_ROTATED',
|
||||
'PRINCIPAL_CREATED', 'PRINCIPAL_UPDATED', 'PRINCIPAL_DELETED',
|
||||
'PRINCIPAL_MEMBER_ADDED', 'PRINCIPAL_MEMBER_REMOVED',
|
||||
'DEPARTMENT_CREATED', 'DEPARTMENT_UPDATED', 'DEPARTMENT_DELETED',
|
||||
'DEPARTMENT_MEMBER_ADDED', 'DEPARTMENT_MEMBER_REMOVED',
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
for val in MISSING_VALUES:
|
||||
op.execute(f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = '{val}'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'auditaction')
|
||||
) THEN
|
||||
ALTER TYPE auditaction ADD VALUE '{val}';
|
||||
END IF;
|
||||
END$$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# PostgreSQL does not support removing enum values; downgrade is a no-op.
|
||||
pass
|
||||
@@ -1,50 +0,0 @@
|
||||
"""add password reset and email verification token tables
|
||||
|
||||
Revision ID: 010_password_reset_email_verify
|
||||
Revises: 009_sync_auditaction_enum
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '010_password_reset_email_verify'
|
||||
down_revision = '009'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'password_reset_tokens',
|
||||
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token', sa.String(128), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=False),
|
||||
sa.Column('used_at', sa.DateTime, nullable=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),
|
||||
)
|
||||
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
|
||||
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
|
||||
|
||||
op.create_table(
|
||||
'email_verification_tokens',
|
||||
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('token', sa.String(128), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=False),
|
||||
sa.Column('used_at', sa.DateTime, nullable=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),
|
||||
)
|
||||
op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'])
|
||||
op.create_index('ix_email_verification_tokens_token', 'email_verification_tokens', ['token'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('email_verification_tokens')
|
||||
op.drop_table('password_reset_tokens')
|
||||
@@ -1,38 +0,0 @@
|
||||
"""add org_invite_tokens table
|
||||
|
||||
Revision ID: 011_org_invite_tokens
|
||||
Revises: 010_password_reset_email_verify
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '011_org_invite_tokens'
|
||||
down_revision = '010_password_reset_email_verify'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'org_invite_tokens',
|
||||
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
|
||||
sa.Column('organization_id', sa.String(36), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('invited_by_id', sa.String(36), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('email', sa.String(255), nullable=False),
|
||||
sa.Column('role', sa.String(64), nullable=False, server_default='member'),
|
||||
sa.Column('token', sa.String(128), nullable=False, unique=True),
|
||||
sa.Column('expires_at', sa.DateTime, nullable=False),
|
||||
sa.Column('accepted_at', sa.DateTime, nullable=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),
|
||||
)
|
||||
op.create_index('ix_org_invite_tokens_organization_id', 'org_invite_tokens', ['organization_id'])
|
||||
op.create_index('ix_org_invite_tokens_email', 'org_invite_tokens', ['email'])
|
||||
op.create_index('ix_org_invite_tokens_token', 'org_invite_tokens', ['token'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('org_invite_tokens')
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Make CA.organization_id nullable (system CA) and add cert_id to sign response
|
||||
|
||||
Revision ID: 012_ca_nullable_org_and_cert_serial
|
||||
Revises: 011_org_invite_tokens
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '012_ca_nullable_org'
|
||||
down_revision = '011_org_invite_tokens'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Allow CA records without an org (e.g. the global system-config CA)
|
||||
with op.batch_alter_table('cas', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
'organization_id',
|
||||
existing_type=sa.String(36),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('cas', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
'organization_id',
|
||||
existing_type=sa.String(36),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Add ca_type column to cas table (user/host).
|
||||
|
||||
Revision ID: 013
|
||||
Revises: d34bfb72844e
|
||||
Create Date: 2026-02-28 23:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '013'
|
||||
down_revision = 'd34bfb72844e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create the enum type first (PostgreSQL requires this)
|
||||
ca_type_enum = sa.Enum('user', 'host', name='ca_type_enum')
|
||||
ca_type_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Add ca_type column with a default of 'user' so existing CAs stay valid
|
||||
op.add_column(
|
||||
'cas',
|
||||
sa.Column(
|
||||
'ca_type',
|
||||
ca_type_enum,
|
||||
nullable=False,
|
||||
server_default='user',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('cas', 'ca_type')
|
||||
# Drop the enum type (PostgreSQL only; SQLite ignores)
|
||||
try:
|
||||
op.execute("DROP TYPE IF EXISTS ca_type_enum")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,44 +0,0 @@
|
||||
"""add_department_cert_policies
|
||||
|
||||
Adds the department_cert_policies table which stores per-department
|
||||
SSH certificate issuance rules:
|
||||
- whether users may choose their own expiry
|
||||
- default and maximum expiry durations
|
||||
- allowed SSH certificate extensions
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "014_add_dept_cert_policy"
|
||||
down_revision = "013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"department_cert_policies",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("department_id", sa.String(36), sa.ForeignKey("departments.id"), nullable=False, unique=True),
|
||||
# Whether users are allowed to specify their own expiry (up to max)
|
||||
sa.Column("allow_user_expiry", sa.Boolean(), nullable=False, server_default="0"),
|
||||
# Default validity in hours (used when user doesn't specify, or not allowed to)
|
||||
sa.Column("default_expiry_hours", sa.Integer(), nullable=False, server_default="1"),
|
||||
# Hard cap on validity; admin cannot be exceeded
|
||||
sa.Column("max_expiry_hours", sa.Integer(), nullable=False, server_default="24"),
|
||||
# JSON list of extension names that are enabled for this department
|
||||
# e.g. ["permit-pty", "permit-agent-forwarding"]
|
||||
sa.Column("allowed_extensions", sa.JSON(), nullable=False, server_default='["permit-pty","permit-agent-forwarding","permit-X11-forwarding","permit-port-forwarding","permit-user-rc"]'),
|
||||
# Admin-defined custom extension names beyond the standard five
|
||||
sa.Column("custom_extensions", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("idx_dept_cert_policy_dept", "department_cert_policies", ["department_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("idx_dept_cert_policy_dept", "department_cert_policies")
|
||||
op.drop_table("department_cert_policies")
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Add USER_SUSPEND and USER_UNSUSPEND to auditaction enum.
|
||||
|
||||
Revision ID: 015_add_user_suspend_audit_actions
|
||||
Revises: 014_add_dept_cert_policy
|
||||
Create Date: 2026-03-02
|
||||
|
||||
USER_SUSPEND and USER_UNSUSPEND were added to the Python AuditAction enum
|
||||
but were never synced to the PostgreSQL auditaction type, causing a
|
||||
DataError (invalid enum value) whenever an admin suspends or unsuspends a user.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "015_user_suspend_audit"
|
||||
down_revision = "014_add_dept_cert_policy"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
for val in ("USER_SUSPEND", "USER_UNSUSPEND"):
|
||||
op.execute(f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = '{val}'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'auditaction')
|
||||
) THEN
|
||||
ALTER TYPE auditaction ADD VALUE '{val}';
|
||||
END IF;
|
||||
END$$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# PostgreSQL does not support removing enum values; downgrade is a no-op.
|
||||
pass
|
||||
@@ -1,168 +0,0 @@
|
||||
"""Encrypt existing plaintext CA private keys at rest.
|
||||
|
||||
Revision ID: 016_encrypt_existing_ca_keys
|
||||
Revises: 015_add_user_suspend_audit_actions
|
||||
Create Date: 2026-03-02
|
||||
|
||||
All CA private keys created before this migration were stored as plaintext PEM
|
||||
strings in the ``cas.private_key`` column. This migration detects those rows
|
||||
(by checking for the absence of the ``$fernet$`` prefix that encrypted values
|
||||
carry) and re-encrypts them with the key derived from ``CA_ENCRYPTION_KEY``.
|
||||
|
||||
The migration is safe to re-run: already-encrypted rows are left untouched.
|
||||
|
||||
Prerequisites
|
||||
-------------
|
||||
``CA_ENCRYPTION_KEY`` must be set in the environment before running this
|
||||
migration. The same value must be configured for the running application.
|
||||
|
||||
To roll back to plaintext (downgrade):
|
||||
The ``downgrade()`` function decrypts all rows back to plaintext PEM. This is
|
||||
provided only for emergency rollback and should not be used in production once
|
||||
the system has been running with encrypted keys.
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Alembic revision identifiers
|
||||
revision = "016_encrypt_ca_keys"
|
||||
down_revision = "015_user_suspend_audit"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_FERNET_PREFIX = "$fernet$"
|
||||
|
||||
|
||||
def _get_fernet():
|
||||
"""Build a Fernet instance from CA_ENCRYPTION_KEY env var."""
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
raw_key = os.environ.get("CA_ENCRYPTION_KEY")
|
||||
if not raw_key:
|
||||
raise RuntimeError(
|
||||
"CA_ENCRYPTION_KEY environment variable is not set. "
|
||||
"Set it before running this migration."
|
||||
)
|
||||
key_bytes = base64.urlsafe_b64encode(hashlib.sha256(raw_key.encode()).digest())
|
||||
return Fernet(key_bytes)
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Encrypt plaintext CA private keys."""
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
|
||||
try:
|
||||
fernet = _get_fernet()
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
# Fetch all non-deleted CA rows
|
||||
rows = session.execute(
|
||||
sa.text("SELECT id, private_key FROM cas WHERE deleted_at IS NULL")
|
||||
).fetchall()
|
||||
|
||||
encrypted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for row in rows:
|
||||
ca_id, private_key = row[0], row[1]
|
||||
|
||||
if not private_key:
|
||||
logger.warning(f"CA {ca_id} has empty private_key — skipping")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if private_key.startswith(_FERNET_PREFIX):
|
||||
# Already encrypted
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Encrypt
|
||||
try:
|
||||
token = fernet.encrypt(private_key.encode()).decode()
|
||||
encrypted_value = f"{_FERNET_PREFIX}{token}"
|
||||
session.execute(
|
||||
sa.text("UPDATE cas SET private_key = :pk WHERE id = :id"),
|
||||
{"pk": encrypted_value, "id": ca_id},
|
||||
)
|
||||
encrypted_count += 1
|
||||
logger.info(f"Encrypted private key for CA {ca_id}")
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
raise RuntimeError(
|
||||
f"Failed to encrypt private key for CA {ca_id}: {exc}"
|
||||
) from exc
|
||||
|
||||
session.commit()
|
||||
logger.info(
|
||||
f"CA key encryption migration complete: "
|
||||
f"{encrypted_count} encrypted, {skipped_count} skipped"
|
||||
)
|
||||
print(
|
||||
f" [016_encrypt_ca_keys] {encrypted_count} CA private key(s) encrypted, "
|
||||
f"{skipped_count} already encrypted or empty."
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Decrypt CA private keys back to plaintext (emergency rollback only)."""
|
||||
bind = op.get_bind()
|
||||
session = Session(bind=bind)
|
||||
|
||||
try:
|
||||
fernet = _get_fernet()
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
rows = session.execute(
|
||||
sa.text("SELECT id, private_key FROM cas WHERE deleted_at IS NULL")
|
||||
).fetchall()
|
||||
|
||||
decrypted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for row in rows:
|
||||
ca_id, private_key = row[0], row[1]
|
||||
|
||||
if not private_key or not private_key.startswith(_FERNET_PREFIX):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
token = private_key[len(_FERNET_PREFIX):]
|
||||
try:
|
||||
from cryptography.fernet import InvalidToken
|
||||
try:
|
||||
plaintext = fernet.decrypt(token.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise RuntimeError(
|
||||
f"Downgrade failed: cannot decrypt CA {ca_id} — wrong key or corrupted data."
|
||||
) from exc
|
||||
|
||||
session.execute(
|
||||
sa.text("UPDATE cas SET private_key = :pk WHERE id = :id"),
|
||||
{"pk": plaintext, "id": ca_id},
|
||||
)
|
||||
decrypted_count += 1
|
||||
logger.warning(f"Decrypted (plaintext restore) private key for CA {ca_id}")
|
||||
except RuntimeError:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
session.commit()
|
||||
logger.warning(
|
||||
f"CA key decryption (downgrade) complete: "
|
||||
f"{decrypted_count} decrypted, {skipped_count} skipped"
|
||||
)
|
||||
print(
|
||||
f" [016_encrypt_ca_keys] DOWNGRADE: {decrypted_count} CA private key(s) "
|
||||
f"decrypted to plaintext. WARNING: keys are now unencrypted at rest."
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Add monotonic serial counter to CAs table.
|
||||
|
||||
Each CA now owns a `next_serial_number` (BigInteger) that is atomically
|
||||
incremented every time a certificate is signed. This guarantees:
|
||||
- Serials are unique per CA
|
||||
- Serials are monotonically increasing (auditable, no gaps by accident)
|
||||
- The value embedded in the OpenSSH certificate matches what is stored
|
||||
in the `ssh_certificates.serial` column
|
||||
|
||||
Revision ID: 017_add_ca_serial_counter
|
||||
Revises: 016_encrypt_ca_keys
|
||||
Create Date: 2026-03-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "017_add_ca_serial_counter"
|
||||
down_revision = "016_encrypt_ca_keys"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("cas", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"next_serial_number",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("cas", schema=None) as batch_op:
|
||||
batch_op.drop_column("next_serial_number")
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Add ORG_OWNERSHIP_TRANSFERRED and USER_HARD_DELETE to auditaction enum.
|
||||
|
||||
Revision ID: 018_audit_enum_values
|
||||
Revises: 017_add_ca_serial_counter
|
||||
Create Date: 2026-03-02
|
||||
|
||||
ORG_OWNERSHIP_TRANSFERRED and USER_HARD_DELETE were added to the Python
|
||||
AuditAction enum but were never synced to the PostgreSQL auditaction type,
|
||||
causing a DataError (invalid enum value) when transferring org ownership
|
||||
or hard-deleting a user.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "018_audit_enum_values"
|
||||
down_revision = "017_add_ca_serial_counter"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ALTER TYPE ... ADD VALUE cannot run inside a transaction block in PostgreSQL.
|
||||
# Alembic has already opened a transaction on the connection by the time our
|
||||
# upgrade() runs, so we must:
|
||||
# 1. Roll back that open transaction on the raw psycopg2 connection.
|
||||
# 2. Switch to autocommit so the ALTER TYPE runs outside any transaction.
|
||||
# 3. Restore the previous state afterwards.
|
||||
conn = op.get_bind()
|
||||
# SQLAlchemy 2.x: conn.connection is a _ConnectionFairy; .driver_connection is psycopg2
|
||||
fairy = conn.connection
|
||||
raw = getattr(fairy, "driver_connection", None) or getattr(fairy, "dbapi_connection", fairy)
|
||||
# Roll back the open transaction so psycopg2 allows us to change autocommit.
|
||||
raw.rollback()
|
||||
old_autocommit = raw.autocommit
|
||||
raw.autocommit = True
|
||||
try:
|
||||
with raw.cursor() as cur:
|
||||
for val in ("ORG_OWNERSHIP_TRANSFERRED", "USER_HARD_DELETE"):
|
||||
cur.execute(
|
||||
"SELECT 1 FROM pg_enum "
|
||||
"WHERE enumlabel = %s "
|
||||
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'auditaction')",
|
||||
(val,),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
cur.execute(f"ALTER TYPE auditaction ADD VALUE '{val}'")
|
||||
finally:
|
||||
raw.autocommit = old_autocommit
|
||||
|
||||
|
||||
def downgrade():
|
||||
# PostgreSQL does not support removing enum values; downgrade is a no-op.
|
||||
pass
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Convert audit_logs.action from auditaction enum to VARCHAR(100).
|
||||
|
||||
Revision ID: 019_audit_varchar
|
||||
Revises: 018_audit_enum_values, db15faee1fb8
|
||||
Create Date: 2026-03-04
|
||||
|
||||
WHY
|
||||
---
|
||||
The PostgreSQL `auditaction` ENUM type must be explicitly altered every time a
|
||||
new AuditAction is added to the Python enum, otherwise the INSERT fails with:
|
||||
|
||||
psycopg2.errors.InvalidTextRepresentation:
|
||||
invalid input value for enum auditaction: "admin.mfa.remove"
|
||||
|
||||
The Python enum was refactored from UPPER_SNAKE_CASE to lower.dot.case string
|
||||
values, but only the UPPER_SNAKE_CASE values exist in the DB type. Rather
|
||||
than add every new value forever, we convert the column to VARCHAR(100) which
|
||||
accepts any string — the Python layer already validates the value via the Enum.
|
||||
|
||||
DATA MIGRATION
|
||||
--------------
|
||||
All existing rows store UPPER_SNAKE_CASE values. We map each one to the
|
||||
corresponding new lower.dot.case string so historical audit logs remain
|
||||
queryable with the current enum.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "019_audit_varchar"
|
||||
down_revision = ("018_audit_enum_values", "db15faee1fb8")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Map every UPPER_SNAKE_CASE DB value → its new lower.dot.case Python value.
|
||||
VALUE_MAP = {
|
||||
"USER_LOGIN": "user.login",
|
||||
"USER_LOGOUT": "user.logout",
|
||||
"USER_REGISTER": "user.register",
|
||||
"USER_UPDATE": "user.update",
|
||||
"USER_DELETE": "user.delete",
|
||||
"USER_HARD_DELETE": "user.hard_delete",
|
||||
"USER_SUSPEND": "user.suspend",
|
||||
"USER_UNSUSPEND": "user.unsuspend",
|
||||
"PASSWORD_CHANGE": "user.password_change",
|
||||
"PASSWORD_RESET": "user.password_reset",
|
||||
"ORG_CREATE": "org.create",
|
||||
"ORG_UPDATE": "org.update",
|
||||
"ORG_DELETE": "org.delete",
|
||||
"ORG_MEMBER_ADD": "org.member.add",
|
||||
"ORG_MEMBER_REMOVE": "org.member.remove",
|
||||
"ORG_MEMBER_ROLE_CHANGE": "org.member.role_change",
|
||||
"ORG_OWNERSHIP_TRANSFERRED": "org.ownership.transferred",
|
||||
"SESSION_CREATE": "session.create",
|
||||
"SESSION_REVOKE": "session.revoke",
|
||||
"AUTH_METHOD_ADD": "auth.method.add",
|
||||
"AUTH_METHOD_REMOVE": "auth.method.remove",
|
||||
"TOTP_ENROLL_INITIATED": "totp.enroll.initiated",
|
||||
"TOTP_ENROLL_COMPLETED": "totp.enroll.completed",
|
||||
"TOTP_VERIFY_SUCCESS": "totp.verify.success",
|
||||
"TOTP_VERIFY_FAILED": "totp.verify.failed",
|
||||
"TOTP_DISABLED": "totp.disabled",
|
||||
"TOTP_BACKUP_CODE_USED": "totp.backup_code.used",
|
||||
"TOTP_BACKUP_CODES_REGENERATED": "totp.backup_codes.regenerated",
|
||||
"WEBAUTHN_REGISTER_INITIATED": "webauthn.register.initiated",
|
||||
"WEBAUTHN_REGISTER_COMPLETED": "webauthn.register.completed",
|
||||
"WEBAUTHN_REGISTER_FAILED": "webauthn.register.failed",
|
||||
"WEBAUTHN_LOGIN_INITIATED": "webauthn.login.initiated",
|
||||
"WEBAUTHN_LOGIN_SUCCESS": "webauthn.login.success",
|
||||
"WEBAUTHN_LOGIN_FAILED": "webauthn.login.failed",
|
||||
"WEBAUTHN_CREDENTIAL_DELETED": "webauthn.credential.deleted",
|
||||
"WEBAUTHN_CREDENTIAL_RENAMED": "webauthn.credential.renamed",
|
||||
"ORG_SECURITY_POLICY_UPDATE": "org.security_policy.update",
|
||||
"USER_SECURITY_POLICY_OVERRIDE_UPDATE":"user.security_policy.override_update",
|
||||
"MFA_POLICY_USER_SUSPENDED": "mfa.policy.user_suspended",
|
||||
"MFA_POLICY_USER_COMPLIANT": "mfa.policy.user_compliant",
|
||||
"EXTERNAL_AUTH_LINK_INITIATED": "external_auth.link.initiated",
|
||||
"EXTERNAL_AUTH_LINK_COMPLETED": "external_auth.link.completed",
|
||||
"EXTERNAL_AUTH_LINK_FAILED": "external_auth.link.failed",
|
||||
"EXTERNAL_AUTH_UNLINK": "external_auth.unlink",
|
||||
"EXTERNAL_AUTH_LOGIN": "external_auth.login",
|
||||
"EXTERNAL_AUTH_LOGIN_FAILED": "external_auth.login.failed",
|
||||
"EXTERNAL_AUTH_TOKEN_REFRESH": "external_auth.token_refresh",
|
||||
"EXTERNAL_AUTH_CONFIG_CREATE": "external_auth.config.create",
|
||||
"EXTERNAL_AUTH_CONFIG_UPDATE": "external_auth.config.update",
|
||||
"EXTERNAL_AUTH_CONFIG_DELETE": "external_auth.config.delete",
|
||||
"SSH_KEY_ADDED": "ssh.key.added",
|
||||
"SSH_KEY_VERIFIED": "ssh.key.verified",
|
||||
"SSH_KEY_DELETED": "ssh.key.deleted",
|
||||
"SSH_KEY_VALIDATION_FAILED": "ssh.key.validation.failed",
|
||||
"SSH_CERT_REQUESTED": "ssh.cert.requested",
|
||||
"SSH_CERT_ISSUED": "ssh.cert.issued",
|
||||
"SSH_CERT_FAILED": "ssh.cert.failed",
|
||||
"SSH_CERT_REVOKED": "ssh.cert.revoked",
|
||||
"SSH_CERT_EXPIRED": "ssh.cert.expired",
|
||||
"CA_CREATED": "ca.created",
|
||||
"CA_UPDATED": "ca.updated",
|
||||
"CA_DELETED": "ca.deleted",
|
||||
"CA_KEY_ROTATED": "ca.key.rotated",
|
||||
"PRINCIPAL_CREATED": "principal.created",
|
||||
"PRINCIPAL_UPDATED": "principal.updated",
|
||||
"PRINCIPAL_DELETED": "principal.deleted",
|
||||
"PRINCIPAL_MEMBER_ADDED": "principal.member.added",
|
||||
"PRINCIPAL_MEMBER_REMOVED": "principal.member.removed",
|
||||
"DEPARTMENT_CREATED": "department.created",
|
||||
"DEPARTMENT_UPDATED": "department.updated",
|
||||
"DEPARTMENT_DELETED": "department.deleted",
|
||||
"DEPARTMENT_MEMBER_ADDED": "department.member.added",
|
||||
"DEPARTMENT_MEMBER_REMOVED": "department.member.removed",
|
||||
}
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. Add a temporary VARCHAR column
|
||||
op.add_column("audit_logs", sa.Column("action_new", sa.String(100), nullable=True))
|
||||
|
||||
# 2. Populate it: map old UPPER_SNAKE_CASE to new lower.dot.case
|
||||
for old_val, new_val in VALUE_MAP.items():
|
||||
conn.execute(
|
||||
sa.text("UPDATE audit_logs SET action_new = :new WHERE action::text = :old"),
|
||||
{"new": new_val, "old": old_val},
|
||||
)
|
||||
|
||||
# 3. Any unmapped rows (shouldn't exist, but be safe): copy as-is
|
||||
conn.execute(sa.text("UPDATE audit_logs SET action_new = action::text WHERE action_new IS NULL"))
|
||||
|
||||
# 4. Drop the old enum column, rename the new one
|
||||
op.drop_column("audit_logs", "action")
|
||||
op.alter_column("audit_logs", "action_new", new_column_name="action", nullable=False)
|
||||
|
||||
# 5. Recreate the index (was on the old column)
|
||||
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
|
||||
op.create_index("idx_audit_user_action", "audit_logs", ["user_id", "action"])
|
||||
|
||||
# 6. Drop the now-unused auditaction enum type
|
||||
op.execute("DROP TYPE IF EXISTS auditaction")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Converting VARCHAR back to a custom enum is complex and lossy for new
|
||||
# values — provide a no-op downgrade. Run a previous backup to revert.
|
||||
pass
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Add ZeroTier / Portal Network models.
|
||||
|
||||
Revision ID: 020_zerotier
|
||||
Revises: 019_audit_varchar
|
||||
Create Date: 2026-03-19
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
revision = "020_zerotier"
|
||||
down_revision = "019_audit_varchar"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op — 023_zerotier_drop_legacy handles everything idempotently.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op — 023_zerotier_drop_legacy handles rollback.
|
||||
pass
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Seed CA serial counters with a timestamp-based starting value.
|
||||
|
||||
Revision ID: 020_ca_serial_timestamp_start
|
||||
Revises: 019_audit_varchar, d34bfb72844e
|
||||
Create Date: 2026-03-06
|
||||
|
||||
WHY
|
||||
---
|
||||
``next_serial_number`` was originally seeded at ``1`` for every CA
|
||||
(``server_default="1"`` in migration 017). Because the
|
||||
``ix_ssh_certificates_serial`` index enforces a globally-unique constraint on
|
||||
the serial column, any two CAs issuing their first certificate would both try
|
||||
to insert serial ``1``, causing a UniqueViolation.
|
||||
|
||||
FIX — new CAs
|
||||
-------------
|
||||
The CA model's Python-side ``default`` is now ``_serial_start()``, which
|
||||
returns ``int(time.time() * 1000)`` (Unix milliseconds) at row-creation time.
|
||||
CAs created after this migration will start their serial counter at the
|
||||
millisecond they were first inserted, so serials are globally unique across
|
||||
CAs and still monotonically increasing within each CA.
|
||||
|
||||
FIX — existing CAs
|
||||
-------------------
|
||||
This migration performs a data migration: any CA whose ``next_serial_number``
|
||||
is still ``<= 2`` (i.e. has issued at most one certificate since the original
|
||||
``1``-based default) is given a new timestamp-based starting value.
|
||||
|
||||
CAs that have already issued many certificates keep their current counter
|
||||
unchanged — their serials are already beyond the low collision-prone range.
|
||||
|
||||
NOTE: the ``server_default`` on the column is intentionally NOT changed here
|
||||
because SQLAlchemy uses the Python-side ``default=_serial_start`` callable for
|
||||
new rows; the ``server_default`` is only a database-level fallback that is
|
||||
never hit when rows are inserted via the ORM.
|
||||
"""
|
||||
import time
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "020_ca_serial_timestamp_start"
|
||||
down_revision = ("3de11c5dc2d5", "d34bfb72844e")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# Update ALL CAs to a timestamp-based starting serial — not just those
|
||||
# stuck at 1. Any CA with a serial below the current ms timestamp is in
|
||||
# the low collision-prone range (serials 1–N where N is tiny). Resetting
|
||||
# every CA to a fresh ms timestamp is safe: the counter only moves forward
|
||||
# from here, and no existing certificate serial is changed.
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id FROM cas")
|
||||
).fetchall()
|
||||
|
||||
for (ca_id,) in rows:
|
||||
new_start = _now_ms()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE cas SET next_serial_number = :val WHERE id = :id"
|
||||
),
|
||||
{"val": new_start, "id": ca_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# There is no safe downgrade for a data migration that assigns new serial
|
||||
# starting points — resetting to 1 would recreate the collision risk.
|
||||
pass
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Merge 020_ca_serial_timestamp_start and 002_add_can_sudo_to_departments into a single head.
|
||||
|
||||
Revision ID: 021_merge_heads
|
||||
Revises: 020_ca_serial_timestamp_start, 002_add_can_sudo_to_departments
|
||||
Create Date: 2026-03-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '021_merge_heads'
|
||||
down_revision = ('020_ca_serial_timestamp_start', '002_add_can_sudo_to_departments')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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
|
||||
@@ -1,393 +0,0 @@
|
||||
"""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}"))
|
||||
@@ -1,291 +0,0 @@
|
||||
"""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}'
|
||||
))
|
||||
@@ -1,101 +0,0 @@
|
||||
"""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'
|
||||
))
|
||||
@@ -1,216 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,105 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Add per-org ZeroTier credentials to organizations table.
|
||||
|
||||
Revision ID: 028_org_zerotier_config
|
||||
Revises: 026_schema_cleanup
|
||||
Create Date: 2026-03-25
|
||||
|
||||
Adds three nullable columns to `organizations`:
|
||||
- zt_api_token VARCHAR(512) — API token (Central) or authtoken.secret (controller)
|
||||
- zt_api_url VARCHAR(512) — base URL of the controller / Central API
|
||||
- zt_api_mode VARCHAR(32) — "central" | "controller"
|
||||
|
||||
When these are NULL the server-level ZEROTIER_API_* env vars are used instead,
|
||||
so existing deployments are fully backwards-compatible with no data migration needed.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "028_org_zerotier_config"
|
||||
down_revision = "027_fix_cert_serial_uniqueness"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _col_exists(conn, table: str, column: str) -> bool:
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if not _col_exists(conn, "organizations", "zt_api_token"):
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column("zt_api_token", sa.String(512), nullable=True),
|
||||
)
|
||||
|
||||
if not _col_exists(conn, "organizations", "zt_api_url"):
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column("zt_api_url", sa.String(512), nullable=True),
|
||||
)
|
||||
|
||||
if not _col_exists(conn, "organizations", "zt_api_mode"):
|
||||
op.add_column(
|
||||
"organizations",
|
||||
sa.Column("zt_api_mode", sa.String(32), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if _col_exists(conn, "organizations", "zt_api_mode"):
|
||||
op.drop_column("organizations", "zt_api_mode")
|
||||
|
||||
if _col_exists(conn, "organizations", "zt_api_url"):
|
||||
op.drop_column("organizations", "zt_api_url")
|
||||
|
||||
if _col_exists(conn, "organizations", "zt_api_token"):
|
||||
op.drop_column("organizations", "zt_api_token")
|
||||
@@ -1,30 +0,0 @@
|
||||
"""add_cert_token_to_ssh_certificates
|
||||
|
||||
Revision ID: 3de11c5dc2d5
|
||||
Revises: 019_audit_varchar
|
||||
Create Date: 2026-03-06 16:04:33.561099
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3de11c5dc2d5'
|
||||
down_revision = '019_audit_varchar'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('ssh_certificates', sa.Column('cert_token', sa.String(length=64), nullable=True))
|
||||
op.create_index(op.f('ix_ssh_certificates_cert_token'), 'ssh_certificates', ['cert_token'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_ssh_certificates_cert_token'), table_name='ssh_certificates')
|
||||
op.drop_column('ssh_certificates', 'cert_token')
|
||||
# ### end Alembic commands ###
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
"""Add can_sudo column to departments table.
|
||||
|
||||
Revision ID: 002_add_can_sudo_to_departments
|
||||
Revises: 001_add_org_api_keys
|
||||
Create Date: 2026-03-07 23:40:30.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '002_add_can_sudo_to_departments'
|
||||
down_revision = '001_add_org_api_keys'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add can_sudo column to departments table
|
||||
op.add_column('departments',
|
||||
sa.Column('can_sudo', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# Create index for performance
|
||||
op.create_index('idx_dept_can_sudo', 'departments',
|
||||
['organization_id', 'can_sudo'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Drop index
|
||||
op.drop_index('idx_dept_can_sudo', table_name='departments')
|
||||
|
||||
# Drop column
|
||||
op.drop_column('departments', 'can_sudo')
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Add organization_api_keys table for API key management.
|
||||
|
||||
Revision ID: 001_add_org_api_keys
|
||||
Revises: 3de11c5dc2d5
|
||||
Create Date: 2026-03-07 23:40:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '001_add_org_api_keys'
|
||||
down_revision = '3de11c5dc2d5'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create organization_api_keys table
|
||||
op.create_table(
|
||||
'organization_api_keys',
|
||||
sa.Column('id', sa.String(36), nullable=False),
|
||||
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), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('key_hash', sa.String(255), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_revoked', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoke_reason', sa.String(255), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('key_hash'),
|
||||
)
|
||||
|
||||
# Create indexes for performance
|
||||
op.create_index('idx_org_api_key_org_active', 'organization_api_keys',
|
||||
['organization_id', 'is_revoked'])
|
||||
op.create_index('idx_api_key_last_used', 'organization_api_keys',
|
||||
['last_used_at'])
|
||||
op.create_index('idx_org_api_key_org_id', 'organization_api_keys',
|
||||
['organization_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Drop indexes
|
||||
op.drop_index('idx_org_api_key_org_id', table_name='organization_api_keys')
|
||||
op.drop_index('idx_api_key_last_used', table_name='organization_api_keys')
|
||||
op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('organization_api_keys')
|
||||
@@ -1,124 +0,0 @@
|
||||
"""totp
|
||||
|
||||
Revision ID: d2fd4f159054
|
||||
Revises: 004
|
||||
Create Date: 2026-02-23 13:21:54.136904
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2fd4f159054'
|
||||
down_revision = '004'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('application_provider_configs',
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('default_redirect_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('additional_config', sa.JSON(), 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.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_application_provider_configs_provider_type'), 'application_provider_configs', ['provider_type'], unique=True)
|
||||
op.create_table('external_provider_configs',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True),
|
||||
sa.Column('auth_url', sa.String(length=2048), nullable=False),
|
||||
sa.Column('token_url', sa.String(length=2048), nullable=False),
|
||||
sa.Column('userinfo_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('jwks_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('scopes', sa.JSON(), nullable=False),
|
||||
sa.Column('redirect_uris', sa.JSON(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type')
|
||||
)
|
||||
op.create_index('idx_provider_config_org', 'external_provider_configs', ['organization_id', 'provider_type'], unique=False)
|
||||
op.create_index(op.f('ix_external_provider_configs_organization_id'), 'external_provider_configs', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_external_provider_configs_provider_type'), 'external_provider_configs', ['provider_type'], unique=False)
|
||||
op.create_table('oauth_states',
|
||||
sa.Column('state', sa.String(length=64), nullable=False),
|
||||
sa.Column('flow_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('nonce', sa.String(length=128), nullable=True),
|
||||
sa.Column('code_verifier', sa.String(length=128), nullable=True),
|
||||
sa.Column('code_challenge', sa.String(length=128), nullable=True),
|
||||
sa.Column('redirect_uri', sa.String(length=2048), nullable=True),
|
||||
sa.Column('return_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('extra_data', sa.JSON(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('used', 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_oauth_states_expires_at'), 'oauth_states', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_oauth_states_organization_id'), 'oauth_states', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_oauth_states_state'), 'oauth_states', ['state'], unique=True)
|
||||
op.create_table('organization_provider_overrides',
|
||||
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('client_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('redirect_url_override', sa.String(length=2048), nullable=True),
|
||||
sa.Column('additional_config', sa.JSON(), 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(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('id'),
|
||||
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_override_type')
|
||||
)
|
||||
op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_organization_provider_overrides_provider_type'), 'organization_provider_overrides', ['provider_type'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_organization_provider_overrides_provider_type'), table_name='organization_provider_overrides')
|
||||
op.drop_index(op.f('ix_organization_provider_overrides_organization_id'), table_name='organization_provider_overrides')
|
||||
op.drop_table('organization_provider_overrides')
|
||||
op.drop_index(op.f('ix_oauth_states_state'), table_name='oauth_states')
|
||||
op.drop_index(op.f('ix_oauth_states_organization_id'), table_name='oauth_states')
|
||||
op.drop_index(op.f('ix_oauth_states_expires_at'), table_name='oauth_states')
|
||||
op.drop_table('oauth_states')
|
||||
op.drop_index(op.f('ix_external_provider_configs_provider_type'), table_name='external_provider_configs')
|
||||
op.drop_index(op.f('ix_external_provider_configs_organization_id'), table_name='external_provider_configs')
|
||||
op.drop_index('idx_provider_config_org', table_name='external_provider_configs')
|
||||
op.drop_table('external_provider_configs')
|
||||
op.drop_index(op.f('ix_application_provider_configs_provider_type'), table_name='application_provider_configs')
|
||||
op.drop_table('application_provider_configs')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,50 +0,0 @@
|
||||
"""add_activation_fields_and_ca_permissions
|
||||
|
||||
Revision ID: d34bfb72844e
|
||||
Revises: 012_ca_nullable_org
|
||||
Create Date: 2026-02-28 18:06:47.328552
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd34bfb72844e'
|
||||
down_revision = '012_ca_nullable_org'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create ca_permissions table
|
||||
op.create_table(
|
||||
'ca_permissions',
|
||||
sa.Column('ca_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('permission', sa.String(length=50), 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(['ca_id'], ['cas.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('ca_id', 'user_id', name='uix_ca_permission'),
|
||||
)
|
||||
op.create_index('ix_ca_permissions_ca_id', 'ca_permissions', ['ca_id'], unique=False)
|
||||
op.create_index('ix_ca_permissions_user_id', 'ca_permissions', ['user_id'], unique=False)
|
||||
|
||||
# Add activation columns to users
|
||||
op.add_column('users', sa.Column('activated', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('true')))
|
||||
op.add_column('users', sa.Column('activation_key', sa.String(length=128), nullable=True))
|
||||
op.create_index('ix_users_activation_key', 'users', ['activation_key'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_users_activation_key', table_name='users')
|
||||
op.drop_column('users', 'activation_key')
|
||||
op.drop_column('users', 'activated')
|
||||
op.drop_index('ix_ca_permissions_user_id', table_name='ca_permissions')
|
||||
op.drop_index('ix_ca_permissions_ca_id', table_name='ca_permissions')
|
||||
op.drop_table('ca_permissions')
|
||||
@@ -1,42 +0,0 @@
|
||||
"""allow_null_ssh_key_id_for_host_certs
|
||||
|
||||
Make ssh_certificates.ssh_key_id nullable so that host certificates issued
|
||||
against a raw server host public key (i.e. not a pre-registered SSHKey record)
|
||||
can be persisted in the database.
|
||||
|
||||
Revision ID: db15faee1fb8
|
||||
Revises: 018_audit_enum_values
|
||||
Create Date: 2026-03-03 16:55:54.030674
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'db15faee1fb8'
|
||||
down_revision = '018_audit_enum_values'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.alter_column(
|
||||
'ssh_certificates',
|
||||
'ssh_key_id',
|
||||
existing_type=sa.VARCHAR(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Null out any rows introduced by host-cert issuance before restoring NOT NULL
|
||||
op.execute(
|
||||
"UPDATE ssh_certificates SET ssh_key_id = '00000000-0000-0000-0000-000000000000' "
|
||||
"WHERE ssh_key_id IS NULL"
|
||||
)
|
||||
op.alter_column(
|
||||
'ssh_certificates',
|
||||
'ssh_key_id',
|
||||
existing_type=sa.VARCHAR(length=36),
|
||||
nullable=False,
|
||||
)
|
||||
Reference in New Issue
Block a user