From 6e96bdde811cfd8fd0ff66b36976ff65dce323bc Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Fri, 3 Apr 2026 23:53:29 +1030 Subject: [PATCH 1/4] chore: update gitignore to exclude opencode and swarm files Add patterns for .opencode/, .swarm/, and SWARM_PLAN.* to gitignore. Remove tracked flask_session binary file. --- .gitignore | 6 +++++- flask_session/2029240f6d1128be89ddc32729463129 | Bin 9 -> 0 bytes 2 files changed, 5 insertions(+), 1 deletion(-) delete mode 100644 flask_session/2029240f6d1128be89ddc32729463129 diff --git a/.gitignore b/.gitignore index 306cd4a..118e26e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,8 @@ Thumbs.db # Project specific *.db -flask_session/ \ No newline at end of file + +# Opencode files and folders +.opencode/ +.swarm/ +SWARM_PLAN.* \ No newline at end of file diff --git a/flask_session/2029240f6d1128be89ddc32729463129 b/flask_session/2029240f6d1128be89ddc32729463129 deleted file mode 100644 index 60b84f8bf0af235343c89653c31a85c904ebfc66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9 QcmZQzU|?uq^=8lm00XQ5{{R30 From 2f2a20adfb84d11e29a56578f01d308df6184f40 Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Sat, 4 Apr 2026 16:50:48 +1030 Subject: [PATCH 2/4] 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 --- migrations/versions/001_base.py | 357 ------ migrations/versions/002_add_totp_support.py | 53 - .../003_create_oidc_jwks_keys_table.py | 50 - migrations/versions/004_policies.py | 122 -- .../005_fix_refresh_token_access_token_id.py | 72 -- .../006_add_departments_principals.py | 127 -- migrations/versions/007_add_ssh_ca_models.py | 173 --- .../versions/008_fix_authmethodtype_enum.py | 53 - .../versions/009_sync_auditaction_enum.py | 61 - .../010_password_reset_email_verify.py | 50 - migrations/versions/011_org_invite_tokens.py | 38 - .../012_ca_nullable_org_and_cert_serial.py | 33 - migrations/versions/013_add_ca_type.py | 42 - .../versions/014_add_dept_cert_policy.py | 44 - .../015_add_user_suspend_audit_actions.py | 37 - .../versions/016_encrypt_existing_ca_keys.py | 168 --- .../versions/017_add_ca_serial_counter.py | 37 - ...ownership_and_hard_delete_audit_actions.py | 52 - ...019_convert_auditaction_enum_to_varchar.py | 143 --- .../versions/020_add_zerotier_models.py | 26 - .../versions/020_ca_serial_timestamp_start.py | 76 -- migrations/versions/021_merge_heads.py | 22 - migrations/versions/022_add_command_events.py | 29 - .../versions/023_zerotier_drop_legacy.py | 393 ------ .../versions/024_fix_zerotier_schema.py | 291 ----- migrations/versions/025_fix_zt_timestamps.py | 101 -- migrations/versions/026_schema_cleanup.py | 216 ---- .../027_fix_cert_serial_uniqueness.py | 105 -- .../versions/028_org_zerotier_config.py | 69 -- ...c2d5_add_cert_token_to_ssh_certificates.py | 30 - .../6a4c4ed4a5c6_initial_migration.py | 1103 +++++++++++++++++ .../versions/add_can_sudo_to_departments.py | 34 - .../add_organization_api_keys_table.py | 56 - migrations/versions/d2fd4f159054_totp.py | 124 -- ...dd_activation_fields_and_ca_permissions.py | 50 - ...b8_allow_null_ssh_key_id_for_host_certs.py | 42 - 36 files changed, 1103 insertions(+), 3376 deletions(-) delete mode 100644 migrations/versions/001_base.py delete mode 100644 migrations/versions/002_add_totp_support.py delete mode 100644 migrations/versions/003_create_oidc_jwks_keys_table.py delete mode 100644 migrations/versions/004_policies.py delete mode 100644 migrations/versions/005_fix_refresh_token_access_token_id.py delete mode 100644 migrations/versions/006_add_departments_principals.py delete mode 100644 migrations/versions/007_add_ssh_ca_models.py delete mode 100644 migrations/versions/008_fix_authmethodtype_enum.py delete mode 100644 migrations/versions/009_sync_auditaction_enum.py delete mode 100644 migrations/versions/010_password_reset_email_verify.py delete mode 100644 migrations/versions/011_org_invite_tokens.py delete mode 100644 migrations/versions/012_ca_nullable_org_and_cert_serial.py delete mode 100644 migrations/versions/013_add_ca_type.py delete mode 100644 migrations/versions/014_add_dept_cert_policy.py delete mode 100644 migrations/versions/015_add_user_suspend_audit_actions.py delete mode 100644 migrations/versions/016_encrypt_existing_ca_keys.py delete mode 100644 migrations/versions/017_add_ca_serial_counter.py delete mode 100644 migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py delete mode 100644 migrations/versions/019_convert_auditaction_enum_to_varchar.py delete mode 100644 migrations/versions/020_add_zerotier_models.py delete mode 100644 migrations/versions/020_ca_serial_timestamp_start.py delete mode 100644 migrations/versions/021_merge_heads.py delete mode 100644 migrations/versions/022_add_command_events.py delete mode 100644 migrations/versions/023_zerotier_drop_legacy.py delete mode 100644 migrations/versions/024_fix_zerotier_schema.py delete mode 100644 migrations/versions/025_fix_zt_timestamps.py delete mode 100644 migrations/versions/026_schema_cleanup.py delete mode 100644 migrations/versions/027_fix_cert_serial_uniqueness.py delete mode 100644 migrations/versions/028_org_zerotier_config.py delete mode 100644 migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py create mode 100644 migrations/versions/6a4c4ed4a5c6_initial_migration.py delete mode 100644 migrations/versions/add_can_sudo_to_departments.py delete mode 100644 migrations/versions/add_organization_api_keys_table.py delete mode 100644 migrations/versions/d2fd4f159054_totp.py delete mode 100644 migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py delete mode 100644 migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py diff --git a/migrations/versions/001_base.py b/migrations/versions/001_base.py deleted file mode 100644 index 3625854..0000000 --- a/migrations/versions/001_base.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/002_add_totp_support.py b/migrations/versions/002_add_totp_support.py deleted file mode 100644 index 3f01f31..0000000 --- a/migrations/versions/002_add_totp_support.py +++ /dev/null @@ -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') diff --git a/migrations/versions/003_create_oidc_jwks_keys_table.py b/migrations/versions/003_create_oidc_jwks_keys_table.py deleted file mode 100644 index 2ad89b0..0000000 --- a/migrations/versions/003_create_oidc_jwks_keys_table.py +++ /dev/null @@ -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') \ No newline at end of file diff --git a/migrations/versions/004_policies.py b/migrations/versions/004_policies.py deleted file mode 100644 index 476d3bd..0000000 --- a/migrations/versions/004_policies.py +++ /dev/null @@ -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 ### \ No newline at end of file diff --git a/migrations/versions/005_fix_refresh_token_access_token_id.py b/migrations/versions/005_fix_refresh_token_access_token_id.py deleted file mode 100644 index 7a183fd..0000000 --- a/migrations/versions/005_fix_refresh_token_access_token_id.py +++ /dev/null @@ -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'] - ) diff --git a/migrations/versions/006_add_departments_principals.py b/migrations/versions/006_add_departments_principals.py deleted file mode 100644 index 8022143..0000000 --- a/migrations/versions/006_add_departments_principals.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/007_add_ssh_ca_models.py b/migrations/versions/007_add_ssh_ca_models.py deleted file mode 100644 index 9749930..0000000 --- a/migrations/versions/007_add_ssh_ca_models.py +++ /dev/null @@ -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') diff --git a/migrations/versions/008_fix_authmethodtype_enum.py b/migrations/versions/008_fix_authmethodtype_enum.py deleted file mode 100644 index ccf56e9..0000000 --- a/migrations/versions/008_fix_authmethodtype_enum.py +++ /dev/null @@ -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 diff --git a/migrations/versions/009_sync_auditaction_enum.py b/migrations/versions/009_sync_auditaction_enum.py deleted file mode 100644 index 59bc210..0000000 --- a/migrations/versions/009_sync_auditaction_enum.py +++ /dev/null @@ -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 diff --git a/migrations/versions/010_password_reset_email_verify.py b/migrations/versions/010_password_reset_email_verify.py deleted file mode 100644 index efb836e..0000000 --- a/migrations/versions/010_password_reset_email_verify.py +++ /dev/null @@ -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') diff --git a/migrations/versions/011_org_invite_tokens.py b/migrations/versions/011_org_invite_tokens.py deleted file mode 100644 index 003da63..0000000 --- a/migrations/versions/011_org_invite_tokens.py +++ /dev/null @@ -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') diff --git a/migrations/versions/012_ca_nullable_org_and_cert_serial.py b/migrations/versions/012_ca_nullable_org_and_cert_serial.py deleted file mode 100644 index 8b7dd0e..0000000 --- a/migrations/versions/012_ca_nullable_org_and_cert_serial.py +++ /dev/null @@ -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, - ) diff --git a/migrations/versions/013_add_ca_type.py b/migrations/versions/013_add_ca_type.py deleted file mode 100644 index 1df4bc2..0000000 --- a/migrations/versions/013_add_ca_type.py +++ /dev/null @@ -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 diff --git a/migrations/versions/014_add_dept_cert_policy.py b/migrations/versions/014_add_dept_cert_policy.py deleted file mode 100644 index 58b6cb3..0000000 --- a/migrations/versions/014_add_dept_cert_policy.py +++ /dev/null @@ -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") diff --git a/migrations/versions/015_add_user_suspend_audit_actions.py b/migrations/versions/015_add_user_suspend_audit_actions.py deleted file mode 100644 index 06834da..0000000 --- a/migrations/versions/015_add_user_suspend_audit_actions.py +++ /dev/null @@ -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 diff --git a/migrations/versions/016_encrypt_existing_ca_keys.py b/migrations/versions/016_encrypt_existing_ca_keys.py deleted file mode 100644 index 91acdda..0000000 --- a/migrations/versions/016_encrypt_existing_ca_keys.py +++ /dev/null @@ -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." - ) diff --git a/migrations/versions/017_add_ca_serial_counter.py b/migrations/versions/017_add_ca_serial_counter.py deleted file mode 100644 index 94b85e9..0000000 --- a/migrations/versions/017_add_ca_serial_counter.py +++ /dev/null @@ -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") diff --git a/migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py b/migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py deleted file mode 100644 index 7cbbd32..0000000 --- a/migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py +++ /dev/null @@ -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 diff --git a/migrations/versions/019_convert_auditaction_enum_to_varchar.py b/migrations/versions/019_convert_auditaction_enum_to_varchar.py deleted file mode 100644 index 1b419ac..0000000 --- a/migrations/versions/019_convert_auditaction_enum_to_varchar.py +++ /dev/null @@ -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 diff --git a/migrations/versions/020_add_zerotier_models.py b/migrations/versions/020_add_zerotier_models.py deleted file mode 100644 index bba02a4..0000000 --- a/migrations/versions/020_add_zerotier_models.py +++ /dev/null @@ -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 diff --git a/migrations/versions/020_ca_serial_timestamp_start.py b/migrations/versions/020_ca_serial_timestamp_start.py deleted file mode 100644 index 2556607..0000000 --- a/migrations/versions/020_ca_serial_timestamp_start.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Seed CA serial counters with a timestamp-based starting value. - -Revision ID: 020_ca_serial_timestamp_start -Revises: 019_audit_varchar, d34bfb72844e -Create Date: 2026-03-06 - -WHY ---- -``next_serial_number`` was originally seeded at ``1`` for every CA -(``server_default="1"`` in migration 017). Because the -``ix_ssh_certificates_serial`` index enforces a globally-unique constraint on -the serial column, any two CAs issuing their first certificate would both try -to insert serial ``1``, causing a UniqueViolation. - -FIX — new CAs -------------- -The CA model's Python-side ``default`` is now ``_serial_start()``, which -returns ``int(time.time() * 1000)`` (Unix milliseconds) at row-creation time. -CAs created after this migration will start their serial counter at the -millisecond they were first inserted, so serials are globally unique across -CAs and still monotonically increasing within each CA. - -FIX — existing CAs -------------------- -This migration performs a data migration: any CA whose ``next_serial_number`` -is still ``<= 2`` (i.e. has issued at most one certificate since the original -``1``-based default) is given a new timestamp-based starting value. - -CAs that have already issued many certificates keep their current counter -unchanged — their serials are already beyond the low collision-prone range. - -NOTE: the ``server_default`` on the column is intentionally NOT changed here -because SQLAlchemy uses the Python-side ``default=_serial_start`` callable for -new rows; the ``server_default`` is only a database-level fallback that is -never hit when rows are inserted via the ORM. -""" -import time -from alembic import op -import sqlalchemy as sa - -revision = "020_ca_serial_timestamp_start" -down_revision = ("3de11c5dc2d5", "d34bfb72844e") -branch_labels = None -depends_on = None - - -def _now_ms() -> int: - return int(time.time() * 1000) - - -def upgrade(): - conn = op.get_bind() - - # Update ALL CAs to a timestamp-based starting serial — not just those - # stuck at 1. Any CA with a serial below the current ms timestamp is in - # the low collision-prone range (serials 1–N where N is tiny). Resetting - # every CA to a fresh ms timestamp is safe: the counter only moves forward - # from here, and no existing certificate serial is changed. - rows = conn.execute( - sa.text("SELECT id FROM cas") - ).fetchall() - - for (ca_id,) in rows: - new_start = _now_ms() - conn.execute( - sa.text( - "UPDATE cas SET next_serial_number = :val WHERE id = :id" - ), - {"val": new_start, "id": ca_id}, - ) - - -def downgrade(): - # There is no safe downgrade for a data migration that assigns new serial - # starting points — resetting to 1 would recreate the collision risk. - pass diff --git a/migrations/versions/021_merge_heads.py b/migrations/versions/021_merge_heads.py deleted file mode 100644 index 9b1e662..0000000 --- a/migrations/versions/021_merge_heads.py +++ /dev/null @@ -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 diff --git a/migrations/versions/022_add_command_events.py b/migrations/versions/022_add_command_events.py deleted file mode 100644 index a410af7..0000000 --- a/migrations/versions/022_add_command_events.py +++ /dev/null @@ -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 diff --git a/migrations/versions/023_zerotier_drop_legacy.py b/migrations/versions/023_zerotier_drop_legacy.py deleted file mode 100644 index c1a1608..0000000 --- a/migrations/versions/023_zerotier_drop_legacy.py +++ /dev/null @@ -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}")) diff --git a/migrations/versions/024_fix_zerotier_schema.py b/migrations/versions/024_fix_zerotier_schema.py deleted file mode 100644 index 11c9aa2..0000000 --- a/migrations/versions/024_fix_zerotier_schema.py +++ /dev/null @@ -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}' - )) diff --git a/migrations/versions/025_fix_zt_timestamps.py b/migrations/versions/025_fix_zt_timestamps.py deleted file mode 100644 index 2b0029f..0000000 --- a/migrations/versions/025_fix_zt_timestamps.py +++ /dev/null @@ -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' - )) diff --git a/migrations/versions/026_schema_cleanup.py b/migrations/versions/026_schema_cleanup.py deleted file mode 100644 index 15e5ef0..0000000 --- a/migrations/versions/026_schema_cleanup.py +++ /dev/null @@ -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") diff --git a/migrations/versions/027_fix_cert_serial_uniqueness.py b/migrations/versions/027_fix_cert_serial_uniqueness.py deleted file mode 100644 index 3441b91..0000000 --- a/migrations/versions/027_fix_cert_serial_uniqueness.py +++ /dev/null @@ -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, - ) diff --git a/migrations/versions/028_org_zerotier_config.py b/migrations/versions/028_org_zerotier_config.py deleted file mode 100644 index c5aad28..0000000 --- a/migrations/versions/028_org_zerotier_config.py +++ /dev/null @@ -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") diff --git a/migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py b/migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py deleted file mode 100644 index 0b066ad..0000000 --- a/migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/6a4c4ed4a5c6_initial_migration.py b/migrations/versions/6a4c4ed4a5c6_initial_migration.py new file mode 100644 index 0000000..d325376 --- /dev/null +++ b/migrations/versions/6a4c4ed4a5c6_initial_migration.py @@ -0,0 +1,1103 @@ +"""Initial migration + +Revision ID: 6a4c4ed4a5c6 +Revises: None +Create Date: 2026-04-03 14:31:49.172415 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6a4c4ed4a5c6' +down_revision = None +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('oidc_jwks_keys', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('kid', sa.String(length=255), nullable=False), + sa.Column('key_type', sa.String(length=50), nullable=False), + sa.Column('algorithm', sa.String(length=50), nullable=False), + sa.Column('private_key', sa.Text(), nullable=False), + sa.Column('public_key', sa.Text(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_primary', sa.Boolean(), 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') + ) + op.create_index(op.f('ix_oidc_jwks_keys_kid'), 'oidc_jwks_keys', ['kid'], unique=True) + 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('zt_api_token', sa.String(length=512), nullable=True), + sa.Column('zt_api_url', sa.String(length=512), nullable=True), + sa.Column('zt_api_mode', sa.String(length=32), 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', 'COMPLIANCE_SUSPENDED', 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('activated', sa.Boolean(), nullable=False), + sa.Column('activation_key', sa.String(length=128), 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_activation_key'), 'users', ['activation_key'], unique=True) + 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.String(length=100), 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', 'TOTP', 'GOOGLE', 'GITHUB', 'MICROSOFT', 'SAML', 'OIDC', 'WEBAUTHN', 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('totp_secret', sa.String(length=32), nullable=True), + sa.Column('totp_backup_codes', sa.JSON(), nullable=True), + sa.Column('totp_verified_at', sa.DateTime(), 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('cas', + sa.Column('organization_id', sa.String(length=36), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('ca_type', sa.Enum('user', 'host', name='catype'), nullable=False), + sa.Column('key_type', sa.Enum('ed25519', 'rsa', 'ecdsa', name='keytype'), 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('next_serial_number', sa.BigInteger(), 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('fingerprint'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('organization_id', 'name', name='uix_org_ca_name') + ) + op.create_index('idx_ca_org_active', 'cas', ['organization_id', 'is_active'], unique=False) + op.create_index(op.f('ix_cas_is_active'), 'cas', ['is_active'], unique=False) + op.create_index(op.f('ix_cas_organization_id'), 'cas', ['organization_id'], unique=False) + 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('can_sudo', 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', 'name', name='uix_org_dept_name') + ) + op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False) + op.create_index(op.f('ix_departments_organization_id'), 'departments', ['organization_id'], unique=False) + op.create_table('devices', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('node_id', sa.String(length=10), nullable=False), + sa.Column('device_nickname', sa.String(length=255), nullable=True), + sa.Column('hostname', sa.String(length=255), nullable=True), + sa.Column('asset_tag', sa.String(length=255), nullable=True), + sa.Column('serial_number', sa.String(length=255), nullable=True), + sa.Column('status', sa.Enum('active', 'inactive', name='device_status'), 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_devices_node_id'), 'devices', ['node_id'], unique=False) + op.create_index(op.f('ix_devices_organization_id'), 'devices', ['organization_id'], unique=False) + op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) + op.create_table('email_verification_tokens', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('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'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True) + op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False) + 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('kill_switch_events', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('target_user_id', sa.String(length=36), nullable=False), + sa.Column('scope', sa.Enum('organization', 'selected_networks', name='kill_switch_scope'), nullable=False), + sa.Column('triggered_by_user_id', sa.String(length=36), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('network_ids', 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.ForeignKeyConstraint(['target_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['triggered_by_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_kill_switch_events_organization_id'), 'kill_switch_events', ['organization_id'], unique=False) + op.create_index(op.f('ix_kill_switch_events_target_user_id'), 'kill_switch_events', ['target_user_id'], unique=False) + 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('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('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('org_invite_tokens', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('invited_by_id', sa.String(length=36), nullable=True), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=64), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('accepted_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'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_org_invite_tokens_email'), 'org_invite_tokens', ['email'], unique=False) + op.create_index(op.f('ix_org_invite_tokens_organization_id'), 'org_invite_tokens', ['organization_id'], unique=False) + op.create_index(op.f('ix_org_invite_tokens_token'), 'org_invite_tokens', ['token'], unique=True) + op.create_table('organization_api_keys', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('key_hash', sa.String(length=255), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('is_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('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') + ) + op.create_index('idx_api_key_last_used', 'organization_api_keys', ['last_used_at'], unique=False) + op.create_index('idx_org_api_key_org_active', 'organization_api_keys', ['organization_id', 'is_revoked'], unique=False) + op.create_index(op.f('ix_organization_api_keys_is_revoked'), 'organization_api_keys', ['is_revoked'], unique=False) + op.create_index(op.f('ix_organization_api_keys_key_hash'), 'organization_api_keys', ['key_hash'], unique=True) + op.create_index(op.f('ix_organization_api_keys_organization_id'), 'organization_api_keys', ['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('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) + 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('password_reset_tokens', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('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'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True) + op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False) + op.create_table('portal_networks', + 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('owner_user_id', sa.String(length=36), nullable=False), + sa.Column('zerotier_network_id', sa.String(length=16), nullable=False), + sa.Column('environment', sa.Enum('production', 'staging', 'development', 'lab', name='network_environment'), nullable=False), + sa.Column('request_mode', sa.Enum('open', 'approval_required', 'invite_only', name='network_request_mode'), nullable=False), + sa.Column('default_activation_lifetime_minutes', sa.Integer(), nullable=False), + sa.Column('max_activation_lifetime_minutes', sa.Integer(), 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.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('organization_id', 'zerotier_network_id', name='uix_org_zt_network_id') + ) + op.create_index(op.f('ix_portal_networks_organization_id'), 'portal_networks', ['organization_id'], unique=False) + op.create_index(op.f('ix_portal_networks_zerotier_network_id'), 'portal_networks', ['zerotier_network_id'], unique=False) + 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_name'), 'principals', ['name'], unique=False) + op.create_index(op.f('ix_principals_organization_id'), 'principals', ['organization_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('is_compliance_only', 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(['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('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') + ) + op.create_index('idx_ssh_key_user_verified', 'ssh_keys', ['user_id', 'verified'], unique=False) + op.create_index(op.f('ix_ssh_keys_fingerprint'), 'ssh_keys', ['fingerprint'], unique=True) + op.create_index(op.f('ix_ssh_keys_user_id'), 'ssh_keys', ['user_id'], unique=False) + op.create_index(op.f('ix_ssh_keys_verified'), 'ssh_keys', ['verified'], unique=False) + 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) + 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'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_ca_permissions_ca_id'), 'ca_permissions', ['ca_id'], unique=False) + op.create_index(op.f('ix_ca_permissions_user_id'), 'ca_permissions', ['user_id'], unique=False) + op.create_table('department_cert_policies', + sa.Column('department_id', sa.String(length=36), nullable=False), + sa.Column('allow_user_expiry', sa.Boolean(), nullable=False), + sa.Column('default_expiry_hours', sa.Integer(), nullable=False), + sa.Column('max_expiry_hours', sa.Integer(), nullable=False), + sa.Column('allowed_extensions', sa.JSON(), nullable=False), + sa.Column('custom_extensions', sa.JSON(), 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.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_department_cert_policies_department_id'), 'department_cert_policies', ['department_id'], unique=True) + 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_department_id'), 'department_memberships', ['department_id'], unique=False) + op.create_index(op.f('ix_department_memberships_user_id'), 'department_memberships', ['user_id'], unique=False) + 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('department_id', 'principal_id', name='uix_dept_principal'), + sa.UniqueConstraint('id') + ) + 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) + 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=255), 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(['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) + 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_principal_id'), 'principal_memberships', ['principal_id'], unique=False) + op.create_index(op.f('ix_principal_memberships_user_id'), 'principal_memberships', ['user_id'], unique=False) + 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=True), + 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='certtype'), 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='certificatestatus'), 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('ca_id', 'serial', name='uq_ssh_certificates_ca_serial'), + sa.UniqueConstraint('id') + ) + op.create_index('idx_cert_revoked', 'ssh_certificates', ['revoked', 'revoked_at'], 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(op.f('ix_ssh_certificates_ca_id'), 'ssh_certificates', ['ca_id'], unique=False) + op.create_index(op.f('ix_ssh_certificates_revoked'), 'ssh_certificates', ['revoked'], unique=False) + op.create_index('ix_ssh_certificates_serial', 'ssh_certificates', ['serial'], 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_status'), 'ssh_certificates', ['status'], unique=False) + op.create_index(op.f('ix_ssh_certificates_user_id'), 'ssh_certificates', ['user_id'], unique=False) + op.create_table('user_network_approvals', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('portal_network_id', sa.String(length=36), nullable=False), + sa.Column('granted_by_user_id', sa.String(length=36), nullable=True), + sa.Column('grant_type', sa.Enum('requested', 'assigned', name='approval_grant_type'), nullable=False), + sa.Column('state', sa.Enum('pending', 'approved', 'rejected', 'revoked', 'suspended', name='approval_state'), nullable=False), + sa.Column('justification', sa.Text(), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['granted_by_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['portal_network_id'], ['portal_networks.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('user_id', 'portal_network_id', 'deleted_at', name='uix_user_network_approval') + ) + op.create_index(op.f('ix_user_network_approvals_organization_id'), 'user_network_approvals', ['organization_id'], unique=False) + op.create_index(op.f('ix_user_network_approvals_portal_network_id'), 'user_network_approvals', ['portal_network_id'], unique=False) + op.create_index(op.f('ix_user_network_approvals_state'), 'user_network_approvals', ['state'], unique=False) + op.create_index(op.f('ix_user_network_approvals_user_id'), 'user_network_approvals', ['user_id'], unique=False) + 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('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) + op.create_index(op.f('ix_certificate_audit_logs_action'), 'certificate_audit_logs', ['action'], unique=False) + 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_table('device_network_memberships', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('device_id', sa.String(length=36), nullable=False), + sa.Column('portal_network_id', sa.String(length=36), nullable=False), + sa.Column('user_network_approval_id', sa.String(length=36), nullable=True), + sa.Column('state', sa.Enum('pending_device_registration', 'pending_request', 'pending_manager_approval', 'approved_inactive', 'joined_deauthorized', 'active_authorized', 'activation_expired', 'suspended', 'revoked', 'rejected', name='membership_state'), nullable=False), + sa.Column('join_seen', sa.Boolean(), nullable=False), + sa.Column('currently_authorized', sa.Boolean(), nullable=False), + sa.Column('approved_for_activation', sa.Boolean(), nullable=False), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['portal_network_id'], ['portal_networks.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['user_network_approval_id'], ['user_network_approvals.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('device_id', 'portal_network_id', 'deleted_at', name='uix_device_network'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_device_network_memberships_device_id'), 'device_network_memberships', ['device_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_organization_id'), 'device_network_memberships', ['organization_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_portal_network_id'), 'device_network_memberships', ['portal_network_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_state'), 'device_network_memberships', ['state'], unique=False) + op.create_index(op.f('ix_device_network_memberships_user_id'), 'device_network_memberships', ['user_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_user_network_approval_id'), 'device_network_memberships', ['user_network_approval_id'], unique=False) + op.create_table('activation_sessions', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('device_network_membership_id', sa.String(length=36), nullable=False), + sa.Column('authenticated_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('ended_at', sa.DateTime(), nullable=True), + sa.Column('end_reason', sa.Enum('expired', 'logout', 'kill_switch', 'manual_revoke', 'approval_revoked', 'admin_action', name='activation_end_reason'), nullable=True), + sa.Column('created_by', 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(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['device_network_membership_id'], ['device_network_memberships.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_activation_sessions_device_network_membership_id'), 'activation_sessions', ['device_network_membership_id'], unique=False) + op.create_index(op.f('ix_activation_sessions_organization_id'), 'activation_sessions', ['organization_id'], unique=False) + op.create_index(op.f('ix_activation_sessions_user_id'), 'activation_sessions', ['user_id'], unique=False) + op.create_table('zerotier_memberships', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('device_network_membership_id', sa.String(length=36), nullable=True), + sa.Column('zerotier_network_id', sa.String(length=16), nullable=False), + sa.Column('node_id', sa.String(length=10), nullable=False), + sa.Column('member_seen', sa.Boolean(), nullable=False), + sa.Column('authorized', sa.Boolean(), nullable=False), + sa.Column('join_seen_at', sa.DateTime(), nullable=True), + sa.Column('last_synced_at', sa.DateTime(), nullable=True), + sa.Column('raw_controller_payload', 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(['device_network_membership_id'], ['device_network_memberships.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('zerotier_network_id', 'node_id', name='uix_zt_network_node') + ) + op.create_index(op.f('ix_zerotier_memberships_device_network_membership_id'), 'zerotier_memberships', ['device_network_membership_id'], unique=False) + op.create_index(op.f('ix_zerotier_memberships_node_id'), 'zerotier_memberships', ['node_id'], unique=False) + op.create_index(op.f('ix_zerotier_memberships_organization_id'), 'zerotier_memberships', ['organization_id'], unique=False) + op.create_index(op.f('ix_zerotier_memberships_zerotier_network_id'), 'zerotier_memberships', ['zerotier_network_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_zerotier_memberships_zerotier_network_id'), table_name='zerotier_memberships') + op.drop_index(op.f('ix_zerotier_memberships_organization_id'), table_name='zerotier_memberships') + op.drop_index(op.f('ix_zerotier_memberships_node_id'), table_name='zerotier_memberships') + op.drop_index(op.f('ix_zerotier_memberships_device_network_membership_id'), table_name='zerotier_memberships') + op.drop_table('zerotier_memberships') + op.drop_index(op.f('ix_activation_sessions_user_id'), table_name='activation_sessions') + op.drop_index(op.f('ix_activation_sessions_organization_id'), table_name='activation_sessions') + op.drop_index(op.f('ix_activation_sessions_device_network_membership_id'), table_name='activation_sessions') + op.drop_table('activation_sessions') + op.drop_index(op.f('ix_device_network_memberships_user_network_approval_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_user_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_state'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_portal_network_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_organization_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_device_id'), table_name='device_network_memberships') + op.drop_table('device_network_memberships') + 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_index(op.f('ix_certificate_audit_logs_action'), table_name='certificate_audit_logs') + 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_table('certificate_audit_logs') + op.drop_index(op.f('ix_user_network_approvals_user_id'), table_name='user_network_approvals') + op.drop_index(op.f('ix_user_network_approvals_state'), table_name='user_network_approvals') + op.drop_index(op.f('ix_user_network_approvals_portal_network_id'), table_name='user_network_approvals') + op.drop_index(op.f('ix_user_network_approvals_organization_id'), table_name='user_network_approvals') + op.drop_table('user_network_approvals') + op.drop_index(op.f('ix_ssh_certificates_user_id'), 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_ssh_key_id'), table_name='ssh_certificates') + op.drop_index('ix_ssh_certificates_serial', 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_ca_id'), 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('idx_cert_revoked', table_name='ssh_certificates') + op.drop_table('ssh_certificates') + op.drop_index(op.f('ix_principal_memberships_user_id'), table_name='principal_memberships') + op.drop_index(op.f('ix_principal_memberships_principal_id'), table_name='principal_memberships') + op.drop_table('principal_memberships') + 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_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_department_memberships_user_id'), table_name='department_memberships') + op.drop_index(op.f('ix_department_memberships_department_id'), table_name='department_memberships') + op.drop_table('department_memberships') + op.drop_index(op.f('ix_department_cert_policies_department_id'), table_name='department_cert_policies') + op.drop_table('department_cert_policies') + op.drop_index(op.f('ix_ca_permissions_user_id'), table_name='ca_permissions') + op.drop_index(op.f('ix_ca_permissions_ca_id'), table_name='ca_permissions') + op.drop_table('ca_permissions') + 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_ssh_keys_verified'), table_name='ssh_keys') + op.drop_index(op.f('ix_ssh_keys_user_id'), table_name='ssh_keys') + op.drop_index(op.f('ix_ssh_keys_fingerprint'), table_name='ssh_keys') + op.drop_index('idx_ssh_key_user_verified', table_name='ssh_keys') + op.drop_table('ssh_keys') + 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_principals_organization_id'), table_name='principals') + op.drop_index(op.f('ix_principals_name'), table_name='principals') + op.drop_table('principals') + op.drop_index(op.f('ix_portal_networks_zerotier_network_id'), table_name='portal_networks') + op.drop_index(op.f('ix_portal_networks_organization_id'), table_name='portal_networks') + op.drop_table('portal_networks') + op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens') + op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens') + op.drop_table('password_reset_tokens') + 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_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_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_organization_api_keys_organization_id'), table_name='organization_api_keys') + op.drop_index(op.f('ix_organization_api_keys_key_hash'), table_name='organization_api_keys') + op.drop_index(op.f('ix_organization_api_keys_is_revoked'), table_name='organization_api_keys') + op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys') + op.drop_index('idx_api_key_last_used', table_name='organization_api_keys') + op.drop_table('organization_api_keys') + op.drop_index(op.f('ix_org_invite_tokens_token'), table_name='org_invite_tokens') + op.drop_index(op.f('ix_org_invite_tokens_organization_id'), table_name='org_invite_tokens') + op.drop_index(op.f('ix_org_invite_tokens_email'), table_name='org_invite_tokens') + op.drop_table('org_invite_tokens') + 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_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_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') + op.drop_index(op.f('ix_kill_switch_events_target_user_id'), table_name='kill_switch_events') + op.drop_index(op.f('ix_kill_switch_events_organization_id'), table_name='kill_switch_events') + op.drop_table('kill_switch_events') + 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_email_verification_tokens_user_id'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_token'), table_name='email_verification_tokens') + op.drop_table('email_verification_tokens') + op.drop_index(op.f('ix_devices_user_id'), table_name='devices') + op.drop_index(op.f('ix_devices_organization_id'), table_name='devices') + op.drop_index(op.f('ix_devices_node_id'), table_name='devices') + op.drop_table('devices') + op.drop_index(op.f('ix_departments_organization_id'), table_name='departments') + op.drop_index(op.f('ix_departments_name'), table_name='departments') + op.drop_table('departments') + op.drop_index(op.f('ix_cas_organization_id'), table_name='cas') + op.drop_index(op.f('ix_cas_is_active'), table_name='cas') + op.drop_index('idx_ca_org_active', table_name='cas') + op.drop_table('cas') + 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_index(op.f('ix_users_activation_key'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_organizations_slug'), table_name='organizations') + op.drop_table('organizations') + op.drop_index(op.f('ix_oidc_jwks_keys_kid'), table_name='oidc_jwks_keys') + op.drop_table('oidc_jwks_keys') + 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 ### diff --git a/migrations/versions/add_can_sudo_to_departments.py b/migrations/versions/add_can_sudo_to_departments.py deleted file mode 100644 index ccc72e0..0000000 --- a/migrations/versions/add_can_sudo_to_departments.py +++ /dev/null @@ -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') diff --git a/migrations/versions/add_organization_api_keys_table.py b/migrations/versions/add_organization_api_keys_table.py deleted file mode 100644 index 1c62994..0000000 --- a/migrations/versions/add_organization_api_keys_table.py +++ /dev/null @@ -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') diff --git a/migrations/versions/d2fd4f159054_totp.py b/migrations/versions/d2fd4f159054_totp.py deleted file mode 100644 index 0ab474e..0000000 --- a/migrations/versions/d2fd4f159054_totp.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py b/migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py deleted file mode 100644 index 83d9f72..0000000 --- a/migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py +++ /dev/null @@ -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') diff --git a/migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py b/migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py deleted file mode 100644 index eb33895..0000000 --- a/migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py +++ /dev/null @@ -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, - ) From d90a06437e6295e04f2a9b164627f27d60cb107c Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Sat, 4 Apr 2026 16:51:19 +1030 Subject: [PATCH 3/4] feat(docker): add Docker deployment configuration Add production-ready Docker setup with multi-stage Dockerfile, docker-compose orchestration for API, PostgreSQL, Redis, and Nginx services. Includes health checks, non-root user execution, and proper networking. - Add multi-stage Dockerfile with gunicorn/gevent workers - Add docker-compose.yml with api, db, redis, nginx services - Add nginx reverse proxy configuration with security headers - Update .env.example with Docker and production variables - Add email provider configuration (Mailgun, SendGrid) - Add requests dependency for HTTP client support - Update documentation with Docker deployment guide - Rebrand project name from Gatehouse to Secuird --- .env.example | 37 ++++++++- .gitignore | 1 + Dockerfile | 68 ++++++++++++++++ README.md | 116 ++++++++++++++++++++++++---- config/base.py | 14 +++- docker-compose.yml | 93 ++++++++++++++++++++++ docker/nginx.conf | 97 +++++++++++++++++++++++ requirements/base.txt | 3 + scripts/README.md | 4 +- scripts/configure_oauth_provider.py | 4 +- 10 files changed, 414 insertions(+), 23 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/nginx.conf diff --git a/.env.example b/.env.example index 537face..4fd332d 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,24 @@ FLASK_APP=manage.py FLASK_ENV=development FLASK_DEBUG=1 -# Database -DATABASE_URL=postgresql://user:password@localhost:5432/gatehouse_dev +# ═════════════════════════════════════════════════════════════════════════════ +# Docker / Production +# ═════════════════════════════════════════════════════════════════════════════ +COMPOSE_PROJECT_NAME=authy2 +FLASK_ENV=production +POSTGRES_USER=authy2 +POSTGRES_PASSWORD=changeme-in-production +POSTGRES_DB=authy2 +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} +SQLALCHEMY_DATABASE_URI=${DATABASE_URL} +REDIS_URL=redis://redis:6379/0 +SESSION_REDIS_URL=redis://redis:6379/0 +RATELIMIT_STORAGE_URL=redis://redis:6379/1 +HTTP_PORT=80 +HTTPS_PORT=443 +API_PORT=5000 + +# Database (overridden by Docker values above) SQLALCHEMY_ECHO=False SQLALCHEMY_LOG_LEVEL=WARNING @@ -15,7 +31,7 @@ CA_ENCRYPTION_KEY=change-me-in-production BCRYPT_LOG_ROUNDS=12 # Session cookies -SESSION_COOKIE_SECURE=False +SESSION_COOKIE_SECURE=True SESSION_COOKIE_SAMESITE=Lax # Only needed when sharing cookies across subdomains (e.g. api.example.com + ui.example.com) # SESSION_COOKIE_DOMAIN=example.com @@ -61,7 +77,7 @@ OIDC_BASE_URL=http://localhost:5000 # WebAuthn # ───────────────────────────────────────────────────────────────────────────── WEBAUTHN_RP_ID=localhost -WEBAUTHN_RP_NAME=Gatehouse +WEBAUTHN_RP_NAME=Secuird WEBAUTHN_ORIGIN=http://localhost:8080 # ───────────────────────────────────────────────────────────────────────────── @@ -81,6 +97,19 @@ SMTP_USERNAME= SMTP_PASSWORD= FROM_ADDRESS=noreply@gatehouse.local +# Email Provider (smtp, mailgun, sendgrid) +# Note: SMTP is the default. Set to "mailgun" or "sendgrid" to use those providers +EMAIL_PROVIDER=smtp + +# Mailgun Configuration (used when EMAIL_PROVIDER=mailgun) +# MAILGUN_API_KEY=your-mailgun-api-key +# MAILGUN_DOMAIN=mg.yourdomain.com +# MAILGUN_API_URL=https://api.mailgun.net/v3 + +# SendGrid Configuration (used when EMAIL_PROVIDER=sendgrid) +# SENDGRID_API_KEY=SG.your-sendgrid-api-key +# SENDGRID_FROM_EMAIL=noreply@yourdomain.com + # ───────────────────────────────────────────────────────────────────────────── # Logging # ───────────────────────────────────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 118e26e..6fcc667 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,7 @@ Thumbs.db # Project specific *.db +flask_session/ # Opencode files and folders .opencode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e795865 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# Multi-stage build for Gatehouse Auth API +# Build stage +FROM python:3.11-slim as builder + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy requirements files +WORKDIR /app +COPY requirements/base.txt requirements/base.txt +COPY requirements/production.txt requirements/production.txt + +# Install dependencies +RUN pip install --no-cache-dir --upgrade pip wheel && \ + pip install --no-cache-dir -r requirements/production.txt + +# Production stage +FROM python:3.11-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd --gid 1000 appgroup && \ + useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application code +WORKDIR /app +COPY --chown=appuser:appgroup . . + +# Create log and session directories +RUN mkdir -p /app/logs /app/flask_session && chown -R appuser:appgroup /app/logs /app/flask_session + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:5000/api/health || exit 1 + +# Run gunicorn with gevent workers +CMD ["gunicorn", "--bind", "0.0.0.0:5000", \ + "--workers", "4", \ + "--worker-class", "gevent", \ + "--worker-connections", "1000", \ + "--timeout", "120", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "--log-level", "info", \ + "wsgi:application"] diff --git a/README.md b/README.md index 7cef62d..604bfc3 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ python scripts/init_db.py 6. **Seed sample data** (optional): ```bash -python scripts/seed_data.py +python -m scripts.seed_data ``` 7. **Run the application**: @@ -77,6 +77,71 @@ python wsgi.py The API will be available at `http://localhost:5000` +## Docker Deployment + +### Prerequisites +- Docker 20.10+ +- Docker Compose 2.0+ + +### Quick Start + +1. **Start all services**: +```bash +docker-compose up -d +``` + +2. **Initialize the database** (run migrations): +```bash +docker-compose exec api python manage.py db upgrade +``` + +3. **Seed sample data** (optional): +```bash +docker-compose exec api python scripts/seed_data.py +``` + +4. **Verify health**: +```bash +curl http://localhost:5000/api/health +``` + +### Useful Commands + +```bash +# View logs +docker-compose logs -f api + +# Run migrations +docker-compose exec api python manage.py db upgrade + +# Open shell in container +docker-compose exec api /bin/bash + +# Rebuild after changes +docker-compose up -d --build + +# Stop all services +docker-compose down +``` + +### Environment Variables + +Copy `.env.example` to `.env` and configure: +- `POSTGRES_USER` / `POSTGRES_PASSWORD` - Database credentials +- `SECRET_KEY` - Flask secret key (required in production) +- `ENCRYPTION_KEY` - Data encryption key +- `CA_ENCRYPTION_KEY` - CA private key encryption +- `CORS_ORIGINS` - Allowed CORS origins (comma-separated) + +### Production Considerations + +- Use a strong `SECRET_KEY` (256-bit random) +- Enable HTTPS via nginx (configure SSL certificates) +- Set `BCRYPT_LOG_ROUNDS=13` for stronger password hashing +- Use Redis persistence (`--appendonly yes`) +- Configure log aggregation as needed + + ## API Endpoints ### Authentication @@ -197,22 +262,45 @@ python manage.py db upgrade -## running seed -python -m scripts.seed_data +## Development Commands -## Running flask in dev +### Run Flask in Development +```bash FLASK_ENV=development flask run --debug --port 8888 +``` + +### Seed Sample Data +```bash +python -m scripts.seed_data +# Or with Docker: +docker-compose exec api python scripts/seed_data.py +``` + +### Database Migration +```bash +# Apply migrations +flask db upgrade + +# With Docker: +docker-compose exec api python manage.py db upgrade +``` + +### SQLite Browser (Development) +```bash +sqlite_web instance/db_file.db --port 9999 --host 0.0.0.0 +``` -# Test creds -## OIDC Client -client_id: acme-portal-001 -client_secret: acme_secret_portal_2024 +## Test Credentials -## User -email: bob@acme-corp.com -password: UserPass123! +### OIDC Client +| Field | Value | +|-------|-------| +| client_id | `acme-portal-001` | +| client_secret | `acme_secret_portal_2024` | - -## Sqlite editor -sqlite_web instance/db_file.db --port 9999 --host 0.0.0.0 \ No newline at end of file +### Test User +| Field | Value | +|-------|-------| +| email | `bob@acme-corp.com` | +| password | `UserPass123!` | \ No newline at end of file diff --git a/config/base.py b/config/base.py index 2ac6adb..cf94022 100644 --- a/config/base.py +++ b/config/base.py @@ -123,7 +123,7 @@ class BaseConfig: # WebAuthn Configuration WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost") - WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Gatehouse") + WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Secuird") WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://ui.webauthn.local") # Frontend URL (for OAuth callback redirects) @@ -140,3 +140,15 @@ class BaseConfig: SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local") + + # Email Provider Configuration + EMAIL_PROVIDER = os.getenv("EMAIL_PROVIDER", "smtp").lower() # smtp, mailgun, sendgrid + + # Mailgun Configuration (used when EMAIL_PROVIDER=mailgun) + MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "") + MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "") + MAILGUN_API_URL = os.getenv("MAILGUN_API_URL", "https://api.mailgun.net/v3") + + # SendGrid Configuration (used when EMAIL_PROVIDER=sendgrid) + SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "") + SENDGRID_FROM_EMAIL = os.getenv("SENDGRID_FROM_EMAIL", "") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b2b9b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + - FLASK_ENV=production + - CORS_ORIGINS=http://192.168.50.124:8080,http://localhost:8080,http://localhost:5173 + - DATABASE_URL=postgresql://${POSTGRES_USER:-gatehouse}:${POSTGRES_PASSWORD:-gatehouse}@db:5432/${POSTGRES_DB:-gatehouse} + - SQLALCHEMY_DATABASE_URI=postgresql://${POSTGRES_USER:-gatehouse}:${POSTGRES_PASSWORD:-gatehouse}@db:5432/${POSTGRES_DB:-gatehouse} + - REDIS_URL=redis://redis:6379/0 + - SESSION_REDIS_URL=redis://redis:6379/0 + - RATELIMIT_STORAGE_URL=redis://redis:6379/1 + ports: + - "${API_PORT:-5000}:5000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - authy2-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + db: + image: postgres:16-alpine + environment: + - POSTGRES_USER=${POSTGRES_USER:-gatehouse} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-gatehouse} + - POSTGRES_DB=${POSTGRES_DB:-gatehouse} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - authy2-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gatehouse} -d ${POSTGRES_DB:-gatehouse}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - authy2-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + nginx: + image: nginx:1.27-alpine + volumes: + - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + depends_on: + - api + networks: + - authy2-network + restart: unless-stopped + healthcheck: + test: ["CMD", "nginx", "-t"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + authy2-network: + driver: bridge + +volumes: + postgres_data: + redis_data: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..ff9c194 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,97 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types text/plain text/css text/xml application/json application/javascript + application/xml application/xml+rss text/javascript application/x-javascript; + + upstream api { + server api:5000; + } + + server { + listen 80; + server_name localhost; + + # Health check endpoint + location /health { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + # API routes + location /api/ { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + + # Increase buffer for larger responses + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 16k; + proxy_busy_buffers_size 24k; + } + + # Catch-all proxy (for any other routes) + location / { + proxy_pass http://api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + } +} diff --git a/requirements/base.txt b/requirements/base.txt index 1f6591d..a96d6c7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -49,5 +49,8 @@ Flask-Limiter==3.5.0 python-json-logger==2.0.7 qrcode[pil] +# HTTP requests +requests>=2.31.0 + # SSH CA Certificate signing sshkey-tools==0.11.3 diff --git a/scripts/README.md b/scripts/README.md index 683c66f..b9d2a27 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,6 @@ -# Gatehouse Scripts +# Secuird Scripts -This directory contains utility scripts for managing and configuring Gatehouse. +This directory contains utility scripts for managing and configuring Secuird. ## OAuth Provider Configuration Script diff --git a/scripts/configure_oauth_provider.py b/scripts/configure_oauth_provider.py index 5372bd7..77d460d 100755 --- a/scripts/configure_oauth_provider.py +++ b/scripts/configure_oauth_provider.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -OAuth Provider Configuration Script for Gatehouse +OAuth Provider Configuration Script for Secuird This script allows administrators to configure OAuth providers at the application level using the new ApplicationProviderConfig architecture. @@ -457,7 +457,7 @@ def delete_provider(args): def main(): """Main entry point for the script.""" parser = argparse.ArgumentParser( - description="Configure OAuth providers for Gatehouse authentication", + description="Configure OAuth providers for Secuird authentication", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: From 41bbdb4bef8d24aee28117cbaee36a302608e28f Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Sat, 4 Apr 2026 16:55:00 +1030 Subject: [PATCH 4/4] feat(email): add provider abstraction and HTML templates Add pluggable email provider system supporting SMTP, Mailgun, and SendGrid with factory pattern for runtime provider selection. Includes branded HTML email templates for verification, password reset, MFA notifications, and organization invites. Also rebrands all email content from Gatehouse to Secuird, adds email provider configuration options, and fixes duplicate log handlers in development mode. --- config/development.py | 12 +- gatehouse_app/__init__.py | 8 +- gatehouse_app/api/v1/auth/core.py | 6 +- gatehouse_app/api/v1/auth/password.py | 16 +- gatehouse_app/api/v1/organizations/invites.py | 6 +- gatehouse_app/api/v1/organizations/members.py | 4 +- gatehouse_app/services/auth_service.py | 2 +- gatehouse_app/services/email_provider.py | 85 +++ gatehouse_app/services/email_templates.py | 498 ++++++++++++++++++ .../services/external_auth/linking.py | 6 +- .../services/notification_service.py | 67 +-- .../services/providers/mailgun_provider.py | 83 +++ .../services/providers/sendgrid_provider.py | 94 ++++ .../services/providers/smtp_provider.py | 92 ++++ gatehouse_app/services/totp_service.py | 6 +- gatehouse_app/services/webauthn_service.py | 2 +- test_email.py | 157 ++++++ 17 files changed, 1068 insertions(+), 76 deletions(-) create mode 100644 gatehouse_app/services/email_provider.py create mode 100644 gatehouse_app/services/email_templates.py create mode 100644 gatehouse_app/services/providers/mailgun_provider.py create mode 100644 gatehouse_app/services/providers/sendgrid_provider.py create mode 100644 gatehouse_app/services/providers/smtp_provider.py create mode 100644 test_email.py diff --git a/config/development.py b/config/development.py index a622243..714a2f3 100644 --- a/config/development.py +++ b/config/development.py @@ -20,13 +20,13 @@ class DevelopmentConfig(BaseConfig): # Reduced bcrypt rounds for faster dev cycles BCRYPT_LOG_ROUNDS = 4 - # Gatehouse React UI URL — OIDC authorize redirects here instead of showing raw HTML + # Secuird React UI URL — OIDC authorize redirects here instead of showing raw HTML OIDC_UI_URL = os.getenv("OIDC_UI_URL", "http://localhost:8080") # Add localhost:8080 (React UI) to CORS allowed origins for OIDC bridge endpoints CORS_ORIGINS = os.getenv( "CORS_ORIGINS", - "http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local" + "http://192.168.50.124:8080,http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local" ).split(",") # ── Email / SMTP ────────────────────────────────────────────────────────── @@ -40,3 +40,11 @@ class DevelopmentConfig(BaseConfig): SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "").lower() == "true" if os.getenv("SMTP_USE_TLS") else int(os.getenv("SMTP_PORT", "1025")) not in (25, 1025) FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local") EMAIL_FROM = FROM_ADDRESS # alias + + # Email Provider Configuration + EMAIL_PROVIDER = os.getenv("EMAIL_PROVIDER", "smtp").lower() + MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY") or None + MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN") or None + MAILGUN_API_URL = os.getenv("MAILGUN_API_URL", "https://api.mailgun.net/v3") + SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY") or None + SENDGRID_FROM_EMAIL = os.getenv("SENDGRID_FROM_EMAIL") or None diff --git a/gatehouse_app/__init__.py b/gatehouse_app/__init__.py index f17c784..dd77dce 100644 --- a/gatehouse_app/__init__.py +++ b/gatehouse_app/__init__.py @@ -191,6 +191,8 @@ def setup_logging(app): root_logger.setLevel(log_level) if app.config.get("LOG_TO_STDOUT"): + # Clear existing handlers on root logger to avoid duplicates + root_logger.handlers.clear() stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) stream_handler.setLevel(log_level) @@ -207,7 +209,8 @@ def setup_logging(app): child_logger.propagate = True child_logger.setLevel(log_level) - # Configure Flask app logger + # Configure Flask app logger - clear handlers so it only propagates to root + app.logger.handlers.clear() app.logger.setLevel(log_level) # Configure SQLAlchemy logging level (also set at module level before DB init) @@ -217,6 +220,9 @@ def setup_logging(app): logging.getLogger('sqlalchemy.dialects').setLevel(sqlalchemy_log_level) logging.getLogger('sqlalchemy.pool').setLevel(sqlalchemy_log_level) + # Suppress watchdog debug logging + logging.getLogger('watchdog.observers.inotify_buffer').setLevel(logging.INFO) + app.logger.info("Application startup") # Test debug log after logging is configured diff --git a/gatehouse_app/api/v1/auth/core.py b/gatehouse_app/api/v1/auth/core.py index 308633b..3c0edc1 100644 --- a/gatehouse_app/api/v1/auth/core.py +++ b/gatehouse_app/api/v1/auth/core.py @@ -32,12 +32,12 @@ def register(): verify_token = EmailVerificationToken.generate(user_id=user.id) app_url = current_app.config.get("APP_URL", "http://localhost:8080") verify_link = f"{app_url}/verify-email?token={verify_token.token}" - subject = "Verify your Gatehouse email address" + subject = "Verify your Secuird email address" body = ( f"Hi {user.full_name or user.email},\n\n" - f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n" + f"Welcome to Secuird! Please verify your email address by clicking the link below (valid for 24 hours):\n" f"{verify_link}\n\n" - f"Gatehouse Security Team" + f"Secuird Security Team" ) NotificationService._send_email_async(to_address=user.email, subject=subject, body=body) except Exception as exc: diff --git a/gatehouse_app/api/v1/auth/password.py b/gatehouse_app/api/v1/auth/password.py index fac32fa..0c34a17 100644 --- a/gatehouse_app/api/v1/auth/password.py +++ b/gatehouse_app/api/v1/auth/password.py @@ -29,14 +29,14 @@ def forgot_password(): reset_link = f"{app_url}/reset-password?token={reset_token.token}" NotificationService._send_email_async( to_address=user.email, - subject="Reset your Gatehouse password", + subject="Reset your Secuird password", body=( f"Hi {user.full_name or user.email},\n\n" - f"You requested a password reset for your Gatehouse account.\n\n" + f"You requested a password reset for your Secuird account.\n\n" f"Click the link below to reset your password (valid for 2 hours):\n" f"{reset_link}\n\n" f"If you did not request this, you can safely ignore this email.\n\n" - f"Gatehouse Security Team" + f"Secuird Security Team" ), ) _logger.info(f"Password reset token generated for user {user.id}") @@ -131,12 +131,12 @@ def resend_verification(): verify_link = f"{app_url}/verify-email?token={verify_token.token}" NotificationService._send_email_async( to_address=user.email, - subject="Verify your Gatehouse email address", + subject="Verify your Secuird email address", body=( f"Hi {user.full_name or user.email},\n\n" f"Please verify your email address by clicking the link below (valid for 24 hours):\n" f"{verify_link}\n\n" - f"Gatehouse Security Team" + f"Secuird Security Team" ), ) _logger.info(f"Verification email sent for user {user.id}") @@ -202,13 +202,13 @@ def resend_activation(): activate_link = f"{app_url}/activate?code={code}" NotificationService._send_email_async( to_address=user.email, - subject="Activate your Gatehouse account", + subject="Activate your Secuird account", body=( f"Hi {user.full_name or user.email},\n\n" - f"Please activate your Gatehouse account by clicking the link below:\n" + f"Please activate your Secuird account by clicking the link below:\n" f"{activate_link}\n\n" f"If you did not create an account, you can safely ignore this email.\n\n" - f"Gatehouse Security Team" + f"Secuird Security Team" ), ) _logger.info(f"Activation email re-sent to {user.id}") diff --git a/gatehouse_app/api/v1/organizations/invites.py b/gatehouse_app/api/v1/organizations/invites.py index 4e3971e..f4ee1fd 100644 --- a/gatehouse_app/api/v1/organizations/invites.py +++ b/gatehouse_app/api/v1/organizations/invites.py @@ -39,12 +39,12 @@ def create_org_invite(org_id): NotificationService._send_email_async( to_address=email, - subject=f"You're invited to join {org.name} on Gatehouse", + subject=f"You're invited to join {org.name} on Secuird", body=( - f"You've been invited to join {org.name} on Gatehouse.\n\n" + f"You've been invited to join {org.name} on Secuird.\n\n" f"Click the link below to accept the invitation (valid for 7 days):\n" f"{invite_link}\n\n" - f"Gatehouse Security Team" + f"Secuird Security Team" ), ) logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}") diff --git a/gatehouse_app/api/v1/organizations/members.py b/gatehouse_app/api/v1/organizations/members.py index c605104..3198237 100644 --- a/gatehouse_app/api/v1/organizations/members.py +++ b/gatehouse_app/api/v1/organizations/members.py @@ -167,9 +167,9 @@ def send_mfa_reminder(org_id, user_id): body=( f"Hi {user.full_name or user.email},\n\n" "Your organization administrator has asked you to set up " - "multi-factor authentication (MFA) on your Gatehouse account.\n\n" + "multi-factor authentication (MFA) on your Secuird account.\n\n" "Please log in and configure MFA as soon as possible.\n\n" - "Gatehouse Security Team" + "Secuird Security Team" ), ) diff --git a/gatehouse_app/services/auth_service.py b/gatehouse_app/services/auth_service.py index fdf09b7..04ec8b0 100644 --- a/gatehouse_app/services/auth_service.py +++ b/gatehouse_app/services/auth_service.py @@ -294,7 +294,7 @@ class AuthService: provisioning_uri = TOTPService.generate_provisioning_uri( user_email=user.email, secret=secret, - issuer="Gatehouse", + issuer="Secuird", ) # Generate QR code data URI diff --git a/gatehouse_app/services/email_provider.py b/gatehouse_app/services/email_provider.py new file mode 100644 index 0000000..331ddd9 --- /dev/null +++ b/gatehouse_app/services/email_provider.py @@ -0,0 +1,85 @@ +"""Email provider interfaces and factory.""" +import logging +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class EmailMessage: + """Email message data structure.""" + to: str + subject: str + body: str + html_body: Optional[str] = None + from_address: Optional[str] = None + + +class EmailProvider(ABC): + """Abstract base class for email providers.""" + + @abstractmethod + def send(self, message: EmailMessage) -> bool: + """ + Send an email message. + + Args: + message: EmailMessage instance containing email details + + Returns: + bool: True if email was sent successfully, False otherwise + """ + pass + + +class NoOpEmailProvider(EmailProvider): + """No-op email provider that logs and returns False.""" + + def send(self, message: EmailMessage) -> bool: + """Log that emails are disabled and return False.""" + logger.info(f"Email disabled - would send to={message.to} subject={message.subject}") + return False + + +class EmailProviderFactory: + """Factory for creating email provider instances.""" + + @staticmethod + def get_provider() -> EmailProvider: + """ + Create an email provider based on EMAIL_PROVIDER config. + + Returns: + EmailProvider: An instance of the appropriate email provider + """ + provider_name = os.getenv("EMAIL_PROVIDER", "smtp").lower() + + if provider_name == "smtp": + try: + from gatehouse_app.services.providers.smtp_provider import SmtpEmailProvider + return SmtpEmailProvider() + except ImportError: + logger.warning("SMTP provider not implemented, using no-op provider") + return NoOpEmailProvider() + + if provider_name == "mailgun": + try: + from gatehouse_app.services.providers.mailgun_provider import MailgunEmailProvider + return MailgunEmailProvider() + except ImportError: + logger.warning("Mailgun provider not implemented, using no-op provider") + return NoOpEmailProvider() + + if provider_name == "sendgrid": + try: + from gatehouse_app.services.providers.sendgrid_provider import SendGridEmailProvider + return SendGridEmailProvider() + except ImportError: + logger.warning("SendGrid provider not implemented, using no-op provider") + return NoOpEmailProvider() + + logger.error(f"Invalid EMAIL_PROVIDER value: {provider_name}, defaulting to no-op provider") + return NoOpEmailProvider() diff --git a/gatehouse_app/services/email_templates.py b/gatehouse_app/services/email_templates.py new file mode 100644 index 0000000..c4ede14 --- /dev/null +++ b/gatehouse_app/services/email_templates.py @@ -0,0 +1,498 @@ +"""HTML Email Templates for Secuird. + +This module provides beautifully designed HTML email templates with +Secuird branding, responsive design, and consistent styling. +""" +import os +from typing import Optional + +from flask import current_app + +PRIMARY_COLOR = "#36b9a6" +PRIMARY_DARK = "#2d9a89" +TEXT_COLOR = "#1e293b" +MUTED_COLOR = "#64748b" +BORDER_COLOR = "#e2e8f0" +BACKGROUND_COLOR = "#f8fafc" +WHITE = "#ffffff" +DANGER_COLOR = "#dc2626" +WARNING_COLOR = "#f59e0b" +SUCCESS_COLOR = "#16a34a" + + +def get_logo_url() -> str: + """Get the email logo URL from config or use default inline SVG.""" + return current_app.config.get("EMAIL_BRAND_LOGO_URL", "") + + +def get_brand_name() -> str: + """Get the brand name from config.""" + return current_app.config.get("EMAIL_BRAND_NAME", "Secuird") + + +def get_support_email() -> str: + """Get the support email from config.""" + return current_app.config.get("EMAIL_SUPPORT_EMAIL", "support@secuird.tech") + + +def get_website_url() -> str: + """Get the website URL from config.""" + return current_app.config.get("EMAIL_WEBSITE_URL", "https://secuird.tech") + + +def get_app_url() -> str: + """Get the app URL from config.""" + return current_app.config.get("APP_URL", "https://secuird.tech") + + +def get_inline_logo() -> str: + """Returns an inline SVG logo as a data URI for email embedding.""" + return ( + '' + '' + '' + '' + ) + + +def get_base_html( + content: str, + subject: str, + preheader: Optional[str] = None, +) -> str: + """Generate the base HTML email template. + + Args: + content: The main content HTML + subject: Email subject (used for title and header) + preheader: Preview text shown in email clients + + Returns: + Complete HTML email string + """ + logo = get_inline_logo() + brand_name = get_brand_name() + support_email = get_support_email() + website_url = get_website_url() + app_url = get_app_url() + current_year = __import__("datetime").datetime.now().year + + return f''' + + + + + + {subject} + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ {logo} +

{brand_name}

+
+
+ {content} +
+
+
+ + + + +
+

© {current_year} {brand_name}. All rights reserved.

+

+ Website +  •  + Support +  •  + App +

+

+ This email was sent because you have an account with {brand_name}. +

+
+
+
+ +''' + + +def get_action_button(link: str, text: str, color: str = PRIMARY_COLOR) -> str: + """Generate an HTML action button. + + Args: + link: The URL the button links to + text: Button text + color: Button background color + + Returns: + HTML button string + """ + return f''' + + + +
+ {text} +
''' + + +def get_alert_box(text: str, alert_type: str = "info", icon: str = "") -> str: + """Generate an alert/highlight box. + + Args: + text: Alert text + alert_type: Type of alert (info, warning, danger, success) + icon: Optional icon emoji or HTML + + Returns: + HTML alert box string + """ + colors = { + "info": (PRIMARY_COLOR, "#e0f2f1"), + "warning": (WARNING_COLOR, "#fef3c7"), + "danger": (DANGER_COLOR, "#fee2e2"), + "success": (SUCCESS_COLOR, "#dcfce7"), + } + border_color, bg_color = colors.get(alert_type, colors["info"]) + + return f''' + + + +
+

{icon} {text}

+
''' + + +def get_detail_row(label: str, value: str) -> str: + """Generate a detail row for email content. + + Args: + label: Field label + value: Field value + + Returns: + HTML row string + """ + return f''' + + + + + + +
{label}{value}
+ + ''' + + +# ============================================================================= +# EMAIL TEMPLATES +# ============================================================================= + + +def build_email_verification_html( + user_name: str, + verify_link: str, + expiry_hours: int = 24, +) -> str: + """Build email verification email (welcome email). + + Args: + user_name: Recipient's name or email + verify_link: Verification link URL + expiry_hours: Hours until link expires + + Returns: + HTML email string + """ + content = f''' +

Welcome to Secuird!

+

+ Hi {user_name}, +

+

+ Thank you for registering with Secuird. Please verify your email address by clicking the button below: +

+ {get_action_button(verify_link, "Verify Email Address")} +

+ This link will expire in {expiry_hours} hours. If you didn't create an account, you can safely ignore this email. +

+ {get_alert_box("For security reasons, please don't forward this email to anyone.", "warning", "⚠️")} + ''' + return get_base_html(content, "Verify your Secuird email address", "Please verify your email address to activate your account") + + +def build_password_reset_html( + user_name: str, + reset_link: str, + expiry_hours: int = 2, +) -> str: + """Build password reset email. + + Args: + user_name: Recipient's name or email + reset_link: Password reset link URL + expiry_hours: Hours until link expires + + Returns: + HTML email string + """ + content = f''' +

Reset Your Password

+

+ Hi {user_name}, +

+

+ We received a request to reset your password. Click the button below to create a new one: +

+ {get_action_button(reset_link, "Reset Password", WARNING_COLOR)} +

+ This link will expire in {expiry_hours} hours. +

+ {get_alert_box("If you didn't request a password reset, your account is secure. You can safely ignore this email.", "info", "🔒")} + ''' + return get_base_html(content, "Reset your Secuird password", "Click the button to reset your password") + + +def build_account_activation_html( + user_name: str, + activation_link: str, +) -> str: + """Build account activation email. + + Args: + user_name: Recipient's name or email + activation_link: Account activation link URL + + Returns: + HTML email string + """ + content = f''' +

Activate Your Account

+

+ Hi {user_name}, +

+

+ Your account has been created but is not yet activated. Click the button below to activate it: +

+ {get_action_button(activation_link, "Activate Account", SUCCESS_COLOR)} + {get_alert_box("If you didn't create an account, you can safely ignore this email.", "warning", "⚠️")} + ''' + return get_base_html(content, "Activate your Secuird account", "Activate your account to get started") + + +def build_mfa_deadline_reminder_html( + user_name: str, + org_name: str, + days_remaining: int, + deadline_date: str, + mfa_methods: str, + setup_link: str, +) -> str: + """Build MFA deadline reminder email. + + Args: + user_name: Recipient's name or email + org_name: Organization name + days_remaining: Days until MFA deadline + deadline_date: Formatted deadline date + mfa_methods: Required MFA methods + setup_link: Link to set up MFA + + Returns: + HTML email string + """ + urgency = "immediate action" if days_remaining <= 3 else "attention required" + + content = f''' +

MFA Enrollment {urgency.title()}

+

+ Dear {user_name}, +

+ {get_alert_box(f"Important: You have {days_remaining} days to set up multi-factor authentication for your account with {org_name}.", "warning", "⏰")} +

+ To maintain access to your account, please complete the following: +

+ + + + +
+

Required MFA Methods:

+

{mfa_methods}

+

Deadline:

+

{deadline_date}

+
+ {get_action_button(setup_link, "Set Up MFA Now", PRIMARY_COLOR)} +

+ If you do not set up MFA by the deadline, your account access will be restricted. +

+

+ If you have questions, please contact your organization administrator. +

+ ''' + subject = f"Action Required: MFA enrollment deadline in {days_remaining} days" + return get_base_html(content, subject, f"MFA enrollment required for {org_name} - {days_remaining} days remaining") + + +def build_mfa_suspension_html( + user_name: str, + org_name: str, + mfa_methods: str, + setup_link: str, +) -> str: + """Build MFA suspension notification email. + + Args: + user_name: Recipient's name or email + org_name: Organization name + mfa_methods: Required MFA methods + setup_link: Link to set up MFA + + Returns: + HTML email string + """ + content = f''' +

Account Access Restricted

+

+ Dear {user_name}, +

+ {get_alert_box("Your account has been suspended because you did not set up multi-factor authentication within the required timeframe.", "danger", "🚫")} +

+ To restore access to your account with {org_name}, please complete the following: +

+ + + + +
+

Required MFA Methods:

+

{mfa_methods}

+

How to Restore Access:

+
    +
  1. Log in to your account (you will see a compliance enrollment screen)
  2. +
  3. Follow the prompts to set up an authenticator app or passkey
  4. +
  5. Once MFA is configured, your access will be restored
  6. +
+
+ {get_action_button(setup_link, "Set Up MFA Now", DANGER_COLOR)} +

+ Need help? Contact your organization administrator. +

+ ''' + return get_base_html(content, "Account Access Restricted - MFA Enrollment Required", "Your account has been suspended due to missing MFA") + + +def build_org_invite_html( + inviter_name: str, + org_name: str, + invite_link: str, + role: str, + expiry_days: int = 7, +) -> str: + """Build organization invite email. + + Args: + inviter_name: Name of person who sent the invite + org_name: Organization name + invite_link: Invitation acceptance link + role: Role the invitee will have + expiry_days: Days until invite expires + + Returns: + HTML email string + """ + content = f''' +

You're Invited to Join {org_name}

+

+ You've been invited by {inviter_name} to join {org_name} on Secuird. +

+ + + + +
+

Invitation Details:

+

Organization: {org_name}

+

Role: {role}

+

This invitation expires in {expiry_days} days

+
+ {get_action_button(invite_link, "Accept Invitation", SUCCESS_COLOR)} +

+ If you did not expect this invitation, you can safely ignore this email. +

+ ''' + return get_base_html(content, f"You're invited to join {org_name} on Secuird", f"You've been invited to join {org_name}") + + +def build_email_verification_resend_html( + user_name: str, + verify_link: str, + expiry_hours: int = 24, +) -> str: + """Build email verification resend email. + + Args: + user_name: Recipient's name or email + verify_link: Verification link URL + expiry_hours: Hours until link expires + + Returns: + HTML email string + """ + content = f''' +

Verify Your Email Address

+

+ Hi {user_name}, +

+

+ Please verify your email address by clicking the button below: +

+ {get_action_button(verify_link, "Verify Email Address")} +

+ This link will expire in {expiry_hours} hours. +

+ {get_alert_box("If you didn't request this, you can safely ignore this email.", "info", "🔒")} + ''' + return get_base_html(content, "Verify your Secuird email address", "Please verify your email address") diff --git a/gatehouse_app/services/external_auth/linking.py b/gatehouse_app/services/external_auth/linking.py index e9fc5f4..f7e8c3a 100644 --- a/gatehouse_app/services/external_auth/linking.py +++ b/gatehouse_app/services/external_auth/linking.py @@ -136,7 +136,7 @@ def complete_link_flow( ).first() if conflicting: raise ExternalAuthError( - f"This {provider_type_str} account is already linked to a different Gatehouse user.", + f"This {provider_type_str} account is already linked to a different Secuird user.", "PROVIDER_ALREADY_LINKED", 409, ) @@ -246,10 +246,10 @@ def authenticate_with_provider( provider_user_id=user_info["provider_user_id"], email=user_info["email"], failure_reason="account_not_found", - error_message="No Gatehouse account matches this external account", + error_message="No Secuird account matches this external account", ) raise ExternalAuthError( - "No Gatehouse account matches this external account. Please register first.", + "No Secuird account matches this external account. Please register first.", "ACCOUNT_NOT_FOUND", 400, ) diff --git a/gatehouse_app/services/notification_service.py b/gatehouse_app/services/notification_service.py index 068fe8f..af84b99 100644 --- a/gatehouse_app/services/notification_service.py +++ b/gatehouse_app/services/notification_service.py @@ -24,6 +24,7 @@ from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyComplia from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy from gatehouse_app.models.user.user import User from gatehouse_app.services.audit_service import AuditService +from gatehouse_app.services.email_provider import EmailMessage, EmailProviderFactory from gatehouse_app.utils.constants import AuditAction logger = logging.getLogger(__name__) @@ -207,7 +208,7 @@ If you do not set up MFA by the deadline, your account access will be restricted If you have any questions, please contact your organization administrator. Best regards, -Gatehouse Security Team +Secuird Security Team """ return body @@ -266,7 +267,7 @@ As a result, your account has been placed in a suspended state. Contact your organization administrator if you have questions. Best regards, -Gatehouse Security Team +Secuird Security Team """ return body @@ -280,12 +281,9 @@ Gatehouse Security Team """Send an email on a daemon thread so the calling request returns immediately. If EMAIL_ENABLED is False, logs instead of sending. - All SMTP exceptions are caught and logged — this method never raises. + All email provider exceptions are caught and logged — this method never raises. The Flask app context is pushed inside the thread so current_app works correctly. """ - import smtplib - from email.mime.multipart import MIMEMultipart - from email.mime.text import MIMEText from flask import current_app app = current_app._get_current_object() # capture real app before leaving request context @@ -295,58 +293,29 @@ Gatehouse Security Team email_enabled = app.config.get(NotificationService.EMAIL_ENABLED_KEY, False) if not email_enabled: logger.info( - f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n" - f"Body: {body[:500]}" + f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}" ) return - smtp_host = app.config.get(NotificationService.SMTP_HOST_KEY, "") - smtp_port_raw = app.config.get(NotificationService.SMTP_PORT_KEY, 587) - smtp_username = app.config.get(NotificationService.SMTP_USERNAME_KEY) - smtp_password = app.config.get(NotificationService.SMTP_PASSWORD_KEY) from_address = app.config.get(NotificationService.FROM_ADDRESS_KEY, "") - missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v] - if missing: - logger.error( - f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. " - f"Would have sent to: {to_address} | Subject: {subject}" - ) - return - - try: - smtp_port = int(smtp_port_raw) - except (TypeError, ValueError): - logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}") - return - - smtp_use_tls = app.config.get( - NotificationService.SMTP_USE_TLS_KEY, - smtp_port not in (25, 1025), + # Build email message + message = EmailMessage( + to=to_address, + subject=subject, + body=body, + html_body=html_body, + from_address=from_address, ) - try: - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = from_address - msg["To"] = to_address - msg.attach(MIMEText(body, "plain")) - if html_body: - msg.attach(MIMEText(html_body, "html")) - - with smtplib.SMTP(smtp_host, smtp_port) as server: - server.ehlo() - if smtp_use_tls: - server.starttls() - server.ehlo() - if smtp_username and smtp_password: - server.login(smtp_username, smtp_password) - server.send_message(msg) + # Get provider and send + provider = EmailProviderFactory.get_provider() + success = provider.send(message) + if success: logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}") - - except Exception as e: - logger.error(f"[EMAIL] Failed to send to {to_address}: {e}") + else: + logger.error(f"[EMAIL] Failed to send to {to_address}") threading.Thread(target=_send, daemon=True).start() diff --git a/gatehouse_app/services/providers/mailgun_provider.py b/gatehouse_app/services/providers/mailgun_provider.py new file mode 100644 index 0000000..62df3f8 --- /dev/null +++ b/gatehouse_app/services/providers/mailgun_provider.py @@ -0,0 +1,83 @@ +"""Mailgun email provider implementation.""" +import logging + +import requests +from flask import current_app + +from gatehouse_app.services.email_provider import EmailMessage, EmailProvider + +logger = logging.getLogger(__name__) + + +class MailgunEmailProvider(EmailProvider): + """Mailgun API-based email provider implementation.""" + + # Configuration keys + MAILGUN_API_KEY = "MAILGUN_API_KEY" + MAILGUN_DOMAIN = "MAILGUN_DOMAIN" + MAILGUN_API_URL = "MAILGUN_API_URL" + FROM_ADDRESS = "FROM_ADDRESS" + + DEFAULT_API_URL = "https://api.mailgun.net/v3" + + def send(self, message: EmailMessage) -> bool: + """Send an email via Mailgun API. + + Args: + message: EmailMessage instance containing email details + + Returns: + bool: True if email was sent successfully, False otherwise + """ + api_key = current_app.config.get(self.MAILGUN_API_KEY) + domain = current_app.config.get(self.MAILGUN_DOMAIN) + api_url = current_app.config.get(self.MAILGUN_API_URL, self.DEFAULT_API_URL) + default_from = current_app.config.get(self.FROM_ADDRESS) + + missing = [k for k, v in [("MAILGUN_API_KEY", api_key), ("MAILGUN_DOMAIN", domain)] if not v] + if missing: + logger.error( + f"[MAILGUN] Cannot send — missing config: {', '.join(missing)}. " + f"Would have sent to: {message.to} | Subject: {message.subject}" + ) + return False + + from_address = message.from_address or default_from + if not from_address: + logger.error( + f"[MAILGUN] Cannot send — missing FROM_ADDRESS. " + f"Would have sent to: {message.to} | Subject: {message.subject}" + ) + return False + + url = f"{api_url}/{domain}/messages" + + data = { + "to": message.to, + "subject": message.subject, + "text": message.body, + "from": from_address, + } + if message.html_body: + data["html"] = message.html_body + + try: + response = requests.post( + url, + auth=("api", api_key), + data=data, + ) + + if response.status_code == 200: + logger.info(f"[MAILGUN] Sent to {message.to} | Subject: {message.subject}") + return True + else: + logger.error( + f"[MAILGUN] Failed to send to {message.to}: from {from_address}" + f"status={response.status_code} body={response.text}" + ) + return False + + except Exception as e: + logger.error(f"[MAILGUN] Exception while sending to {message.to}: {e}") + return False diff --git a/gatehouse_app/services/providers/sendgrid_provider.py b/gatehouse_app/services/providers/sendgrid_provider.py new file mode 100644 index 0000000..924d2fb --- /dev/null +++ b/gatehouse_app/services/providers/sendgrid_provider.py @@ -0,0 +1,94 @@ +"""SendGrid email provider implementation.""" +import logging + +import requests +from flask import current_app + +from gatehouse_app.services.email_provider import EmailMessage, EmailProvider + +logger = logging.getLogger(__name__) + + +class SendGridEmailProvider(EmailProvider): + """SendGrid API-based email provider implementation.""" + + # Configuration keys + SENDGRID_API_KEY = "SENDGRID_API_KEY" + SENDGRID_FROM_EMAIL = "SENDGRID_FROM_EMAIL" + FROM_ADDRESS = "FROM_ADDRESS" + + API_URL = "https://api.sendgrid.com/v3/mail/send" + + def send(self, message: EmailMessage) -> bool: + """Send an email via SendGrid API. + + Args: + message: EmailMessage instance containing email details + + Returns: + bool: True if email was sent successfully, False otherwise + """ + api_key = current_app.config.get(self.SENDGRID_API_KEY) + default_from = current_app.config.get(self.SENDGRID_FROM_EMAIL) + fallback_from = current_app.config.get(self.FROM_ADDRESS) + + if not api_key: + logger.error( + f"[SENDGRID] Cannot send — missing SENDGRID_API_KEY config. " + f"Would have sent to: {message.to} | Subject: {message.subject}" + ) + return False + + from_address = message.from_address or default_from or fallback_from + if not from_address: + logger.error( + f"[SENDGRID] Cannot send — missing from address (SENDGRID_FROM_EMAIL or FROM_ADDRESS). " + f"Would have sent to: {message.to} | Subject: {message.subject}" + ) + return False + + payload = { + "personalizations": [ + { + "to": [{"email": message.to}] + } + ], + "from": {"email": from_address}, + "subject": message.subject, + "content": [ + { + "type": "text/plain", + "value": message.body + } + ] + } + + if message.html_body: + payload["content"].append({ + "type": "text/html", + "value": message.html_body + }) + + try: + response = requests.post( + self.API_URL, + json=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + ) + + if response.status_code == 202: + logger.info(f"[SENDGRID] Sent to {message.to} | Subject: {message.subject}") + return True + else: + logger.error( + f"[SENDGRID] Failed to send to {message.to}: " + f"status={response.status_code} body={response.text}" + ) + return False + + except Exception as e: + logger.error(f"[SENDGRID] Exception while sending to {message.to}: {e}") + return False diff --git a/gatehouse_app/services/providers/smtp_provider.py b/gatehouse_app/services/providers/smtp_provider.py new file mode 100644 index 0000000..85c7b8e --- /dev/null +++ b/gatehouse_app/services/providers/smtp_provider.py @@ -0,0 +1,92 @@ +"""SMTP email provider implementation.""" +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from flask import current_app + +from gatehouse_app.services.email_provider import EmailMessage, EmailProvider + +logger = logging.getLogger(__name__) + + +class SmtpEmailProvider(EmailProvider): + """SMTP-based email provider implementation.""" + + # Configuration keys + EMAIL_ENABLED_KEY = "EMAIL_ENABLED" + SMTP_HOST_KEY = "SMTP_HOST" + SMTP_PORT_KEY = "SMTP_PORT" + SMTP_USERNAME_KEY = "SMTP_USERNAME" + SMTP_PASSWORD_KEY = "SMTP_PASSWORD" + SMTP_USE_TLS_KEY = "SMTP_USE_TLS" + FROM_ADDRESS_KEY = "FROM_ADDRESS" + + def send(self, message: EmailMessage) -> bool: + """Send an email via SMTP. + + Args: + message: EmailMessage instance containing email details + + Returns: + bool: True if email was sent successfully, False otherwise + """ + email_enabled = current_app.config.get(self.EMAIL_ENABLED_KEY, False) + if not email_enabled: + logger.info( + f"[EMAIL DISABLED] Would have sent to: {message.to} | " + f"Subject: {message.subject}" + ) + return False + + smtp_host = current_app.config.get(self.SMTP_HOST_KEY, "") + from_address = message.from_address or current_app.config.get(self.FROM_ADDRESS_KEY, "") + + missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v] + if missing: + logger.error( + f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. " + f"Would have sent to: {message.to} | Subject: {message.subject}" + ) + return False + + smtp_port_raw = current_app.config.get(self.SMTP_PORT_KEY, 587) + try: + smtp_port = int(smtp_port_raw) + except (TypeError, ValueError): + logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}") + return False + + smtp_username = current_app.config.get(self.SMTP_USERNAME_KEY) + smtp_password = current_app.config.get(self.SMTP_PASSWORD_KEY) + + smtp_use_tls = current_app.config.get( + self.SMTP_USE_TLS_KEY, + smtp_port not in (25, 1025), + ) + + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = message.subject + msg["From"] = from_address + msg["To"] = message.to + msg.attach(MIMEText(message.body, "plain")) + if message.html_body: + msg.attach(MIMEText(message.html_body, "html")) + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.ehlo() + if smtp_use_tls: + server.starttls() + server.ehlo() + if smtp_username and smtp_password: + server.login(smtp_username, smtp_password) + server.send_message(msg) + + logger.info(f"[EMAIL] Sent to {message.to} | Subject: {message.subject}") + return True + + except Exception as e: + logger.error(f"[EMAIL] Failed to send to {message.to}: {e}") + return False diff --git a/gatehouse_app/services/totp_service.py b/gatehouse_app/services/totp_service.py index c667e3e..e733dce 100644 --- a/gatehouse_app/services/totp_service.py +++ b/gatehouse_app/services/totp_service.py @@ -75,14 +75,14 @@ class TOTPService: return secret @staticmethod - def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Gatehouse") -> str: + def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Secuird") -> str: """ Generate provisioning URI for QR code. Args: user_email: User's email address secret: TOTP secret (base32 encoded) - issuer: Issuer name (default: "Gatehouse") + issuer: Issuer name (default: "Secuird") Returns: otpauth:// URI for QR code generation @@ -90,7 +90,7 @@ class TOTPService: Example: >>> uri = TOTPService.generate_provisioning_uri("user@example.com", "JBSWY3DPEHPK3PXP") >>> print(uri) - otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse + otpauth://totp/Secuird:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Secuird """ totp = pyotp.TOTP(secret) uri = totp.provisioning_uri(name=user_email, issuer_name=issuer) diff --git a/gatehouse_app/services/webauthn_service.py b/gatehouse_app/services/webauthn_service.py index 9f953d3..59df130 100644 --- a/gatehouse_app/services/webauthn_service.py +++ b/gatehouse_app/services/webauthn_service.py @@ -167,7 +167,7 @@ class WebAuthnService: # Get RP configuration rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost') - rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Gatehouse') + rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Secuird') # Generate user ID (Base64URL encoded) user_id = cls._base64url_encode(user.id.encode('utf-8')) diff --git a/test_email.py b/test_email.py new file mode 100644 index 0000000..6061a5c --- /dev/null +++ b/test_email.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Test script to verify email delivery with HTML templates.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from dotenv import load_dotenv +load_dotenv(".env") + +from gatehouse_app import create_app +from gatehouse_app.services.email_provider import EmailProviderFactory, EmailMessage +from gatehouse_app.services import email_templates + + +def test_html_email(): + app = create_app() + + print("Testing HTML Email Templates...") + print(f"EMAIL_PROVIDER: {app.config.get('EMAIL_PROVIDER')}") + print(f"MAILGUN_DOMAIN: {app.config.get('MAILGUN_DOMAIN')}") + + with app.app_context(): + provider = EmailProviderFactory.get_provider() + print(f"Provider class: {provider.__class__.__name__}") + + # Test 1: Email Verification + print("\n--- Test 1: Email Verification ---") + html_body = email_templates.build_email_verification_html( + user_name="Cory", + verify_link="https://secuird.tech/verify-email?token=test123", + expiry_hours=24, + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="Verify your Secuird email address", + body="Plain text version: Please verify your email by clicking the link.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + # Test 2: Password Reset + print("\n--- Test 2: Password Reset ---") + html_body = email_templates.build_password_reset_html( + user_name="Cory", + reset_link="https://secuird.tech/reset-password?token=test456", + expiry_hours=2, + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="Reset your Secuird password", + body="Plain text version: Reset your password by clicking the link.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + # Test 3: MFA Deadline Reminder + print("\n--- Test 3: MFA Deadline Reminder ---") + html_body = email_templates.build_mfa_deadline_reminder_html( + user_name="Cory", + org_name="Acme Corp", + days_remaining=5, + deadline_date="2026-04-09 23:59 UTC", + mfa_methods="Authenticator app (TOTP) or Passkey (WebAuthn)", + setup_link="https://secuird.tech/settings/security", + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="Action Required: MFA enrollment deadline in 5 days", + body="Plain text version: MFA enrollment required.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + # Test 4: MFA Suspension + print("\n--- Test 4: MFA Suspension ---") + html_body = email_templates.build_mfa_suspension_html( + user_name="Cory", + org_name="Acme Corp", + mfa_methods="Authenticator app (TOTP) or Passkey (WebAuthn)", + setup_link="https://secuird.tech/settings/security", + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="Account Access Restricted - MFA Enrollment Required", + body="Plain text version: Your account has been suspended.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + # Test 5: Organization Invite + print("\n--- Test 5: Organization Invite ---") + html_body = email_templates.build_org_invite_html( + inviter_name="Admin User", + org_name="Acme Corporation", + invite_link="https://secuird.tech/invite?token=test789", + role="Member", + expiry_days=7, + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="You're invited to join Acme Corporation on Secuird", + body="Plain text version: You've been invited to join.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + # Test 6: Account Activation + print("\n--- Test 6: Account Activation ---") + html_body = email_templates.build_account_activation_html( + user_name="Cory", + activation_link="https://secuird.tech/activate?code=testabc", + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="Activate your Secuird account", + body="Plain text version: Activate your account.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + # Test 7: Email Verification Resend + print("\n--- Test 7: Email Verification Resend ---") + html_body = email_templates.build_email_verification_resend_html( + user_name="Cory", + verify_link="https://secuird.tech/verify-email?token=testxyz", + expiry_hours=24, + ) + message = EmailMessage( + to="cory@hawkvelt.id.au", + subject="Verify your Secuird email address", + body="Plain text version: Please verify your email.", + html_body=html_body, + from_address="Secuird ", + ) + success = provider.send(message) + print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}") + + print("\n" + "=" * 50) + print("All 7 email templates sent!") + print("=" * 50) + + +if __name__ == "__main__": + test_html_email()