"""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: 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' ), ) op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True) op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id']) with op.batch_alter_table('users') as batch: batch.drop_index('ix_users_api_key') 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')