"""users.password_revoked_at — remember that SEC-10 dropped a preset password Revision ID: 0078_password_revoked_at Revises: 0077_plex_playback_class Create Date: 2026-09-02 SEC-10 (`auth.revoke_preset_password_if_unverified`) nulls `password_hash` when an account whose email was never verified is attested (Google adoption, or admin approval). That left NO trace, and password-reset eligibility keyed off `password_hash IS NOT NULL` — so such an account could neither sign in (no password) nor request a reset link (not "eligible"): a permanent lockout with a silent, uniform response at every step. This column is that trace. A row with `password_revoked_at` set is a local account that HAD a password and had it revoked, so the reset flow treats it as eligible again. Setting a password (reset-confirm or /auth/set-password) clears the stamp. A Google-only account that never had a password keeps it NULL and stays reset-ineligible by design — its owner adds a password from Settings while signed in. Backfill: the signature is exact and narrow — a local (google_sub IS NULL), non-demo account with NO password could ONLY have got there through the revoke. Registration always sets a hash, the setup wizard sets one too, every Google row carries a google_sub (the adoption path assigns it BEFORE revoking), and nothing in the codebase ever clears google_sub. Stamp those with the current time so they become recoverable without operator intervention. Deliberately NOT also requiring `email_verified = false`, even though the revoke only fires on an unverified account: verifying is exactly what such a victim does NEXT. The revoke does not invalidate the outstanding verification token, so clicking the emailed link sets email_verified=True while password_hash stays NULL — and that row has no route back at all (reset ineligible, resend refuses an already-verified address, and set-password needs a signed-in session the account can no longer obtain: no password to sign in with, and no Google identity). Those are the victims who most need the stamp, so the predicate must not exclude them. """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0078_password_revoked_at" down_revision: Union[str, None] = "0077_plex_playback_class" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.add_column( "users", sa.Column("password_revoked_at", sa.DateTime(timezone=True), nullable=True), ) op.execute( "UPDATE users SET password_revoked_at = now() " "WHERE password_hash IS NULL " "AND google_sub IS NULL " "AND is_demo = false" ) def downgrade() -> None: op.drop_column("users", "password_revoked_at")