"""password resets: an admin-issued, one-hour link; sessions an account can outlive Revision ID: 0033 Revises: 0032 Create Date: 2026-10-07 A forgotten password used to need a hand on the database (#2939 ยง2), and the app has no mail path to send a reset by. An admin makes a reset link for the account and hands it over (#5173). Only the token's SHA-256 hash is stored. `users.session_epoch` is what lets a reset sign the account out everywhere. Sessions are signed cookies held by the browser, so the server can't delete them; each one carries the epoch it was signed in under, and a reset moves the account's epoch on. It starts at 0, the value a cookie from before this migration is read as, so nobody is signed out by the upgrade itself. ## Downgrade Drops the table and the column. Outstanding reset links stop working; sessions keep working, since nothing checks an epoch any more. """ import sqlalchemy as sa from alembic import op from sqlalchemy.dialects.postgresql import UUID revision = "0033" down_revision = "0032" branch_labels = None depends_on = None def upgrade() -> None: op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0")) op.create_table( "password_resets", sa.Column("id", UUID(as_uuid=True), primary_key=True), sa.Column("token_hash", sa.Text(), nullable=False, unique=True), sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"]) def downgrade() -> None: op.drop_index("ix_password_resets_user_id", table_name="password_resets") op.drop_table("password_resets") op.drop_column("users", "session_epoch")