Skip to content

Commit 4e8d5ad

Browse files
committed
feat: add account associations data model
Resolves #19052 Signed-off-by: Mike Fiedler <[email protected]> # Conflicts: # tests/common/db/accounts.py # tests/unit/accounts/test_models.py # warehouse/accounts/models.py
1 parent 5428407 commit 4e8d5ad

File tree

4 files changed

+203
-0
lines changed

4 files changed

+203
-0
lines changed

tests/common/db/accounts.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from argon2 import PasswordHasher
99

1010
from warehouse.accounts.models import (
11+
AccountAssociation,
1112
Email,
1213
ProhibitedEmailDomain,
1314
ProhibitedUserName,
@@ -140,3 +141,17 @@ class Meta:
140141

141142
user = factory.SubFactory(UserFactory)
142143
ip_address = REMOTE_ADDR
144+
145+
146+
class AccountAssociationFactory(WarehouseFactory):
147+
class Meta:
148+
model = AccountAssociation
149+
150+
user = factory.SubFactory(UserFactory)
151+
service = "github"
152+
external_user_id = factory.Sequence(lambda n: f"{n}")
153+
external_username = factory.Faker("user_name")
154+
access_token = factory.Faker("sha256")
155+
refresh_token = None
156+
token_expires_at = None
157+
metadata_ = {}

tests/unit/accounts/test_models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from warehouse.utils.security_policy import principals_for
1919

2020
from ...common.db.accounts import (
21+
AccountAssociationFactory as DBAccountAssociationFactory,
2122
EmailFactory as DBEmailFactory,
2223
UserEventFactory as DBUserEventFactory,
2324
UserFactory as DBUserFactory,
@@ -317,6 +318,20 @@ def test_user_projects_is_ordered_by_name(self, db_session):
317318

318319
assert user.projects == [project2, project3, project1]
319320

321+
def test_account_associations_is_ordered_by_created_desc(self, db_session):
322+
user = DBUserFactory.create()
323+
assoc1 = DBAccountAssociationFactory.create(
324+
user=user, created=datetime.datetime(2020, 1, 1)
325+
)
326+
assoc2 = DBAccountAssociationFactory.create(
327+
user=user, created=datetime.datetime(2021, 1, 1)
328+
)
329+
assoc3 = DBAccountAssociationFactory.create(
330+
user=user, created=datetime.datetime(2022, 1, 1)
331+
)
332+
333+
assert user.account_associations == [assoc3, assoc2, assoc1]
334+
320335

321336
class TestUserUniqueLogin:
322337
def test_repr(self, db_session):

warehouse/accounts/models.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,13 @@ class User(SitemapMixin, HasObservers, HasObservations, HasEvents, db.Model):
185185
)
186186
)
187187

188+
account_associations: Mapped[list[AccountAssociation]] = orm.relationship(
189+
back_populates="user",
190+
cascade="all, delete-orphan",
191+
lazy=True,
192+
order_by="AccountAssociation.created.desc()",
193+
)
194+
188195
@property
189196
def primary_email(self):
190197
primaries = [x for x in self.emails if x.primary]
@@ -526,3 +533,67 @@ def __repr__(self):
526533
f"ip_address={self.ip_address!r}, "
527534
f"status={self.status!r})>"
528535
)
536+
537+
538+
class AccountAssociation(db.Model):
539+
"""
540+
External account associations (e.g., Oauth Providers) linked to PyPI user accounts.
541+
542+
Allows users to connect multiple external accounts from
543+
the same third-party service to their PyPI account.
544+
"""
545+
546+
__tablename__ = "account_associations"
547+
__table_args__ = (
548+
# Prevent the same external account from being linked to multiple PyPI accounts
549+
UniqueConstraint(
550+
"service", "external_user_id", name="account_associations_service_external"
551+
),
552+
Index("account_associations_user_service", "user_id", "service"),
553+
)
554+
555+
__repr__ = make_repr("service", "external_username")
556+
557+
# Timestamps
558+
created: Mapped[datetime_now]
559+
updated: Mapped[datetime.datetime | None] = mapped_column(onupdate=sql.func.now())
560+
561+
# User relationship
562+
_user_id: Mapped[UUID] = mapped_column(
563+
"user_id",
564+
PG_UUID(as_uuid=True),
565+
ForeignKey("users.id", ondelete="CASCADE"),
566+
nullable=False,
567+
index=True,
568+
)
569+
user: Mapped[User] = orm.relationship(User, back_populates="account_associations")
570+
571+
# Service information
572+
service: Mapped[str] = mapped_column(
573+
String(50), nullable=False, comment="External service name (e.g., 'github')"
574+
)
575+
external_user_id: Mapped[str] = mapped_column(
576+
String(255), nullable=False, comment="User ID from external service"
577+
)
578+
external_username: Mapped[str] = mapped_column(
579+
String(255), nullable=False, comment="Username from external service"
580+
)
581+
582+
# OAuth tokens (encrypted at application layer before storage)
583+
access_token: Mapped[str | None] = mapped_column(
584+
comment="Encrypted OAuth access token"
585+
)
586+
refresh_token: Mapped[str | None] = mapped_column(
587+
comment="Encrypted OAuth refresh token"
588+
)
589+
token_expires_at: Mapped[datetime.datetime | None] = mapped_column(
590+
comment="When the access token expires"
591+
)
592+
593+
# Additional service-specific metadata
594+
metadata_: Mapped[dict | None] = mapped_column(
595+
"metadata",
596+
JSONB,
597+
server_default=sql.text("'{}'"),
598+
comment="Service-specific metadata (profile info, scopes, etc.)",
599+
)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""
3+
Add account_associations table
4+
5+
Revision ID: 500a38d28fab
6+
Revises: 4c20f2342bba
7+
Create Date: 2025-11-12 17:25:42.687250
8+
"""
9+
10+
import sqlalchemy as sa
11+
12+
from alembic import op
13+
from sqlalchemy.dialects import postgresql
14+
15+
from warehouse.utils.db.types import TZDateTime
16+
17+
revision = "500a38d28fab"
18+
down_revision = "4c20f2342bba"
19+
20+
21+
def upgrade():
22+
op.create_table(
23+
"account_associations",
24+
sa.Column(
25+
"created", sa.DateTime(), server_default=sa.text("now()"), nullable=False
26+
),
27+
sa.Column("updated", TZDateTime(), nullable=True),
28+
sa.Column("user_id", sa.UUID(), nullable=False),
29+
sa.Column(
30+
"service",
31+
sa.String(length=50),
32+
nullable=False,
33+
comment="External service name (e.g., 'github')",
34+
),
35+
sa.Column(
36+
"external_user_id",
37+
sa.String(length=255),
38+
nullable=False,
39+
comment="User ID from external service",
40+
),
41+
sa.Column(
42+
"external_username",
43+
sa.String(length=255),
44+
nullable=False,
45+
comment="Username from external service",
46+
),
47+
sa.Column(
48+
"access_token",
49+
sa.String(),
50+
nullable=True,
51+
comment="Encrypted OAuth access token",
52+
),
53+
sa.Column(
54+
"refresh_token",
55+
sa.String(),
56+
nullable=True,
57+
comment="Encrypted OAuth refresh token",
58+
),
59+
sa.Column(
60+
"token_expires_at",
61+
TZDateTime(),
62+
nullable=True,
63+
comment="When the access token expires",
64+
),
65+
sa.Column(
66+
"metadata",
67+
postgresql.JSONB(astext_type=sa.Text()),
68+
server_default=sa.text("'{}'"),
69+
nullable=True,
70+
comment="Service-specific metadata (profile info, scopes, etc.)",
71+
),
72+
sa.Column(
73+
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
74+
),
75+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
76+
sa.PrimaryKeyConstraint("id"),
77+
sa.UniqueConstraint(
78+
"service", "external_user_id", name="account_associations_service_external"
79+
),
80+
)
81+
op.create_index(
82+
"account_associations_user_service",
83+
"account_associations",
84+
["user_id", "service"],
85+
unique=False,
86+
)
87+
op.create_index(
88+
op.f("ix_account_associations_user_id"),
89+
"account_associations",
90+
["user_id"],
91+
unique=False,
92+
)
93+
94+
95+
def downgrade():
96+
op.drop_index(
97+
op.f("ix_account_associations_user_id"), table_name="account_associations"
98+
)
99+
op.drop_index(
100+
"account_associations_user_service", table_name="account_associations"
101+
)
102+
op.drop_table("account_associations")

0 commit comments

Comments
 (0)