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:
2026-04-04 16:50:48 +10:30
parent 6e96bdde81
commit 2f2a20adfb
36 changed files with 1103 additions and 3376 deletions
-357
View File
@@ -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')
-122
View File
@@ -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,
)
-42
View File
@@ -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 1N 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
-22
View File
@@ -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'
))
-216
View File
@@ -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')
-124
View File
@@ -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,
)