From 453ba3fefc8896c9e1f6ef8e32bfa288c905b148 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 03:24:07 -0400 Subject: [PATCH] feat(auth): make users.password_hash nullable for OAuth-only accounts Co-Authored-By: Claude Opus 4.7 --- ...b055a1593e_users_password_hash_nullable.py | 47 +++++++++++++++++++ backend/app/models/user.py | 2 +- backend/tests/test_user_password_nullable.py | 23 +++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py create mode 100644 backend/tests/test_user_password_nullable.py diff --git a/backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py b/backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py new file mode 100644 index 00000000..209baaec --- /dev/null +++ b/backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py @@ -0,0 +1,47 @@ +"""users password_hash nullable + +Revision ID: 5bb055a1593e +Revises: b1fad5ddf357 +Create Date: 2026-05-06 07:23:21.480252 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5bb055a1593e' +down_revision: Union[str, None] = 'b1fad5ddf357' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "users", + "password_hash", + existing_type=sa.String(255), + nullable=True, + ) + + +def downgrade() -> None: + # NOTE: downgrade is non-trivial if any OAuth-only users exist. + # This downgrade fails fast in that case rather than corrupting data. + conn = op.get_bind() + null_count = conn.execute( + sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL") + ).scalar() + if null_count and null_count > 0: + raise RuntimeError( + f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. " + "Set passwords or delete those rows before downgrading." + ) + op.alter_column( + "users", + "password_hash", + existing_type=sa.String(255), + nullable=False, + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e1274183..064e57ba 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -33,7 +33,7 @@ class User(Base): default=uuid.uuid4 ) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer") is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/tests/test_user_password_nullable.py b/backend/tests/test_user_password_nullable.py new file mode 100644 index 00000000..6e13cdd0 --- /dev/null +++ b/backend/tests/test_user_password_nullable.py @@ -0,0 +1,23 @@ +import pytest +from app.models.user import User +from app.models.account import Account + + +@pytest.mark.asyncio +async def test_user_can_be_created_without_password_hash(test_db): + """OAuth-only users have password_hash=None and the row should commit cleanly.""" + account = Account(name="OAuthShop", display_code="OAUTH001") + test_db.add(account) + await test_db.flush() + + user = User( + email="oauth-only@example.com", + name="OAuth Only", + password_hash=None, + account_id=account.id, + account_role="engineer", + ) + test_db.add(user) + await test_db.commit() + await test_db.refresh(user) + assert user.password_hash is None