Make usernames one case-insensitive namespace (B-57)
The login throttle keyed on body.username.lower() while the lookup matched User.username exactly, so "Bob" and "bob" were two accounts sharing one rate-limit bucket — each able to lock the other out — and registration happily accepted near-duplicate names, which on a custodial system is an impersonation vector. Uniqueness is now the database's job: a unique index on lower(username), with register and login both matching through func.lower(). The name is still stored exactly as typed, since that's what /admin and the audit log display, and the username pattern is ASCII-only so lower() is the whole of the normalization. The migration refuses to run if two existing accounts differ only by case. It can't merge or rename one automatically: both are custodial accounts that may hold funds, so that would be the migration silently deciding who owns what. It names the collisions and leaves them to the operator — the container runs `alembic upgrade head` at startup, so it surfaces as a refusal to start rather than a half-applied schema. Verified both directions against a scratch DB, plus `alembic check` (clean) and the collision guard actually firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"""case-insensitive usernames (B-57)
|
||||
|
||||
Revision ID: c1d4a97b5e10
|
||||
Revises: be71fdac734e
|
||||
Create Date: 2026-08-03 18:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c1d4a97b5e10'
|
||||
down_revision: Union[str, Sequence[str], None] = 'be71fdac734e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# The index cannot be created while two accounts differ only by case, and
|
||||
# nothing here may guess which of them is the "real" one: both are custodial
|
||||
# accounts that may hold funds, so merging or renaming one automatically would
|
||||
# be the migration silently deciding who owns what. Fail loudly instead, naming
|
||||
# the collisions, and let the operator rename one account (and tell that user)
|
||||
# before retrying. The container runs `alembic upgrade head` at startup, so this
|
||||
# surfaces as a refusal to start rather than as a half-applied schema.
|
||||
collisions = op.get_bind().exec_driver_sql(
|
||||
"SELECT group_concat(username, ', ') FROM users "
|
||||
"GROUP BY lower(username) HAVING count(*) > 1"
|
||||
).fetchall()
|
||||
if collisions:
|
||||
groups = "; ".join(row[0] for row in collisions)
|
||||
raise RuntimeError(
|
||||
"cannot enforce case-insensitive usernames: these accounts differ only "
|
||||
f"by case and must be resolved by hand first — {groups}"
|
||||
)
|
||||
|
||||
op.create_index("ix_users_username_lower", "users", [sa.text("lower(username)")], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_users_username_lower", table_name="users")
|
||||
Reference in New Issue
Block a user