"""API keys as SHA-256 hashes, one per session (#41) Replaces the plaintext `users.api_key` column with an `api_keys` table: one row per login, holding only the SHA-256 hex digest, an expiry and the last use. Existing keys are invalidated on purpose, not migrated - the plaintext is exactly what must stop existing, so everybody logs in once again. Revision ID: 015_api_key_hash Revises: 014_vision_fk_ondelete Create Date: 2026-10-06 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = '015_api_key_hash' down_revision: Union[str, None] = '014_vision_fk_ondelete' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Idempotent on purpose (#71): a database born from create_all has no # `uq_users_api_key` (only the unique index `ix_users_api_key`), and on # MySQL a run that dies half way (no transactional DDL) leaves `api_keys` # created and `alembic_version` still at 014. Every step checks first. insp = sa.inspect(op.get_bind()) if 'api_keys' not in insp.get_table_names(): op.create_table( 'api_keys', sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('key_hash', sa.String(64), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('expires_at', sa.DateTime(), nullable=False), sa.Column('last_used_at', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint( ['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE' ), ) existing_idx: set[str] = set() else: existing_idx = {i['name'] for i in insp.get_indexes('api_keys')} if 'ix_api_keys_key_hash' not in existing_idx: op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True) if 'ix_api_keys_user_id' not in existing_idx: op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id']) if 'api_key' not in {c['name'] for c in insp.get_columns('users')}: return # column already gone: nothing else to do user_idx = {i['name'] for i in insp.get_indexes('users')} user_uq = {u['name'] for u in insp.get_unique_constraints('users')} with op.batch_alter_table('users') as batch: if 'ix_users_api_key' in user_idx: batch.drop_index('ix_users_api_key') if 'uq_users_api_key' in user_uq: batch.drop_constraint('uq_users_api_key', type_='unique') batch.drop_column('api_key') def downgrade() -> None: with op.batch_alter_table('users') as batch: batch.add_column(sa.Column('api_key', sa.String(64), nullable=True)) batch.create_unique_constraint('uq_users_api_key', ['api_key']) batch.create_index('ix_users_api_key', ['api_key']) op.drop_index('ix_api_keys_user_id', table_name='api_keys') op.drop_index('ix_api_keys_key_hash', table_name='api_keys') op.drop_table('api_keys')