diff --git a/backend/alembic/versions/0078_password_revoked_at.py b/backend/alembic/versions/0078_password_revoked_at.py index c6811125..e025c264 100644 --- a/backend/alembic/versions/0078_password_revoked_at.py +++ b/backend/alembic/versions/0078_password_revoked_at.py @@ -15,10 +15,19 @@ and had it revoked, so the reset flow treats it as eligible again. Setting a pas /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 pre-existing victims of the lockout can't be identified with certainty, but the signature -is exact and narrow — a local (google_sub IS NULL), non-demo account with no password whose email is -still unverified could ONLY have got there through the revoke, since registration always sets a hash. -Stamp those with the current time so they become recoverable without operator intervention. +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 @@ -41,8 +50,7 @@ def upgrade() -> None: "UPDATE users SET password_revoked_at = now() " "WHERE password_hash IS NULL " "AND google_sub IS NULL " - "AND is_demo = false " - "AND email_verified = false" + "AND is_demo = false" )