Skip to content

Commit c5d3338

Browse files
diewdurbin
andauthored
Data migration for UserUniqueLogin/IpAddress (#19124)
* Data migration for UserUniqueLogin/IpAddress * Update the model as well * Linting * Only do index if it does not exist * Seasoning * Update tests --------- Co-authored-by: Ee Durbin <ewdurbin@gmail.com>
1 parent 2fe49fa commit c5d3338

File tree

6 files changed

+141
-10
lines changed

6 files changed

+141
-10
lines changed

tests/common/db/accounts.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from ...common.constants import REMOTE_ADDR
2121
from .base import WarehouseFactory
22+
from .ip_addresses import IpAddressFactory
2223

2324
fake = faker.Faker()
2425

@@ -140,3 +141,10 @@ class Meta:
140141

141142
user = factory.SubFactory(UserFactory)
142143
ip_address = REMOTE_ADDR
144+
ip_address_id = factory.LazyAttribute(
145+
lambda o: (
146+
IpAddressFactory.create(ip_address=o.ip_address).id
147+
if o.ip_address is not None
148+
else None
149+
)
150+
)

tests/unit/accounts/test_services.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2095,10 +2095,12 @@ def test_device_is_pending_not_expired(self, user_service, monkeypatch):
20952095

20962096
def test_device_is_pending_and_expired(self, user_service, monkeypatch):
20972097
user = UserFactory.create(with_verified_primary_email=True)
2098+
ip_address = IpAddressFactory.create(ip_address=REMOTE_ADDR)
20982099
UserUniqueLoginFactory.create(
20992100
user=user,
2100-
ip_address=REMOTE_ADDR,
21012101
status="pending",
2102+
ip_address=str(ip_address.ip_address),
2103+
ip_address_id=ip_address.id,
21022104
created=datetime.datetime(1970, 1, 1),
21032105
expires=datetime.datetime(1970, 1, 1),
21042106
)
@@ -2115,7 +2117,7 @@ def test_device_is_pending_and_expired(self, user_service, monkeypatch):
21152117
)
21162118
},
21172119
find_service=lambda *a, **kw: token_service,
2118-
ip_address=IpAddressFactory.create(ip_address=REMOTE_ADDR),
2120+
ip_address=ip_address,
21192121
)
21202122

21212123
assert not user_service.device_is_known(user.id, user_service.request)

tests/unit/accounts/test_views.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,8 @@ def test_login_with_remembered_device_confirms_unique_login(
641641

642642
UserUniqueLoginFactory.create(
643643
user=user,
644-
ip_address=db_request.remote_addr,
644+
ip_address=str(db_request.ip_address.ip_address),
645+
ip_address_id=db_request.ip_address.id,
645646
status=UniqueLoginStatus.PENDING,
646647
)
647648

@@ -699,7 +700,8 @@ def test_login_updates_last_used(self, monkeypatch, db_request, pyramid_services
699700
past_timestamp = datetime.datetime(1970, 1, 1)
700701
UserUniqueLoginFactory.create(
701702
user=user,
702-
ip_address=db_request.remote_addr,
703+
ip_address=str(db_request.ip_address.ip_address),
704+
ip_address_id=db_request.ip_address.id,
703705
status=UniqueLoginStatus.CONFIRMED,
704706
last_used=past_timestamp,
705707
)
@@ -5507,7 +5509,9 @@ def test_ip_address_mismatch(self, db_request):
55075509
def test_success(self, monkeypatch, db_request):
55085510
user = UserFactory.create(last_login=datetime.datetime.now(datetime.UTC))
55095511
unique_login = UserUniqueLoginFactory.create(
5510-
user=user, ip_address=db_request.remote_addr
5512+
user=user,
5513+
ip_address=str(db_request.ip_address.ip_address),
5514+
ip_address_id=db_request.ip_address.id,
55115515
)
55125516
db_request.user = None
55135517
db_request.params = {"token": "foo"}

tests/unit/admin/views/test_ipaddresses.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,15 @@ def test_ip_address_found_no_unique_logins(self, db_request):
6464
assert result == {"ip_address": ip_address, "unique_logins": []}
6565

6666
def test_ip_address_found_with_unique_logins(self, db_request):
67-
ip_address = IpAddressFactory()
6867
unique_login = UserUniqueLoginFactory.create(
69-
ip_address=str(ip_address.ip_address)
68+
ip_address=str(db_request.ip_address.ip_address),
69+
ip_address_id=str(db_request.ip_address.id),
7070
)
71-
db_request.matchdict["ip_address"] = str(ip_address.ip_address)
71+
db_request.matchdict["ip_address"] = str(db_request.ip_address.ip_address)
7272

7373
result = ip_views.ip_address_detail(db_request)
7474

75-
assert result == {"ip_address": ip_address, "unique_logins": [unique_login]}
75+
assert result == {
76+
"ip_address": db_request.ip_address,
77+
"unique_logins": [unique_login],
78+
}

warehouse/accounts/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ class UserUniqueLogin(db.Model):
514514

515515
ip_address_id: Mapped[int] = mapped_column(
516516
ForeignKey("ip_addresses.id", onupdate="CASCADE", ondelete="CASCADE"),
517-
nullable=True,
517+
nullable=False,
518518
index=True,
519519
)
520520
ip_address: Mapped[str] = mapped_column(String, nullable=False)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""
3+
Data migration for UserUniqueLogin.ip_address_id
4+
5+
Revision ID: be443e514e3e
6+
Revises: df52c3746740
7+
Create Date: 2025-12-02 17:32:29.770684
8+
"""
9+
10+
11+
import os
12+
13+
import sqlalchemy as sa
14+
15+
from alembic import op
16+
17+
revision = "be443e514e3e"
18+
down_revision = "df52c3746740"
19+
20+
21+
def _get_remaining_logins_to_update(conn):
22+
return conn.execute(
23+
sa.text("SELECT COUNT(*) FROM user_unique_logins WHERE ip_address_id IS NULL")
24+
).scalar_one()
25+
26+
27+
def _get_remaining_ips_to_insert(conn):
28+
return conn.execute(
29+
sa.text(
30+
"""
31+
SELECT COUNT(DISTINCT user_unique_logins.ip_address)
32+
FROM user_unique_logins
33+
LEFT JOIN ip_addresses ON user_unique_logins.ip_address::inet = ip_addresses.ip_address
34+
WHERE ip_addresses.id IS NULL AND user_unique_logins.ip_address IS NOT NULL
35+
"""
36+
)
37+
).scalar_one()
38+
39+
40+
def upgrade():
41+
op.execute("SET statement_timeout = 120000")
42+
op.execute("SET lock_timeout = 120000")
43+
44+
op.create_index(
45+
"ix_user_unique_logins_ip_address_migration",
46+
"user_unique_logins",
47+
["ip_address"],
48+
unique=False,
49+
if_not_exists=True,
50+
)
51+
52+
bind = op.get_bind()
53+
batch_size = 1000
54+
salt = os.environ.get("WAREHOUSE_IP_SALT")
55+
56+
while _get_remaining_ips_to_insert(bind) > 0:
57+
bind.execute(
58+
sa.text(
59+
f"""
60+
INSERT INTO ip_addresses (ip_address, hashed_ip_address)
61+
SELECT
62+
DISTINCT user_unique_logins.ip_address::inet,
63+
encode(
64+
digest(
65+
CONCAT(user_unique_logins.ip_address, '{salt}'),
66+
'sha256'
67+
),
68+
'hex'
69+
)
70+
FROM user_unique_logins
71+
LEFT JOIN ip_addresses ON user_unique_logins.ip_address::inet = ip_addresses.ip_address
72+
WHERE ip_addresses.id IS NULL AND user_unique_logins.ip_address IS NOT NULL
73+
LIMIT :batch_size
74+
"""
75+
),
76+
{"batch_size": batch_size},
77+
)
78+
bind.commit()
79+
80+
while _get_remaining_logins_to_update(bind) > 0:
81+
bind.execute(
82+
sa.text(
83+
"""
84+
UPDATE user_unique_logins
85+
SET ip_address_id = ip_addresses.id
86+
FROM ip_addresses
87+
WHERE
88+
user_unique_logins.ip_address::inet = ip_addresses.ip_address AND
89+
user_unique_logins.ip_address_id IS NULL AND
90+
user_unique_logins.id IN (
91+
SELECT id
92+
FROM user_unique_logins
93+
WHERE ip_address_id IS NULL
94+
ORDER BY id
95+
LIMIT :batch_size
96+
)
97+
"""
98+
),
99+
{"batch_size": batch_size},
100+
)
101+
bind.commit()
102+
103+
# Finally make the ip_address_id column non-nullable
104+
op.alter_column("user_unique_logins", "ip_address_id", nullable=False)
105+
106+
op.drop_index(
107+
"ix_user_unique_logins_ip_address_migration",
108+
"user_unique_logins",
109+
if_exists=True,
110+
)
111+
112+
113+
def downgrade():
114+
op.alter_column("user_unique_logins", "ip_address_id", nullable=True)

0 commit comments

Comments
 (0)