Skip to content

Commit e012d33

Browse files
MyECLPay: unique constraints and for update lock to prevent race conditions (#727)
Add unique constraint for ed25519_public_key, refund transaction_id and confirmation_token and Request transaction_id Lock `for update` when selecting wallet and transactions to prevent race conditions Require #729 to ensure there is a unique db *transaction* per endpoint which may either be committed or rollback
1 parent b048cdc commit e012d33

File tree

5 files changed

+136
-20
lines changed

5 files changed

+136
-20
lines changed

app/core/myeclpay/cruds_myeclpay.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
from sqlalchemy import and_, delete, or_, select, update
66
from sqlalchemy.ext.asyncio import AsyncSession
7-
from sqlalchemy.orm import selectinload
7+
from sqlalchemy.orm import noload, selectinload
88

99
from app.core.memberships import schemas_memberships
1010
from app.core.myeclpay import models_myeclpay, schemas_myeclpay
11+
from app.core.myeclpay.exceptions_myeclpay import WalletNotFoundOnUpdateError
1112
from app.core.myeclpay.types_myeclpay import (
1213
TransactionStatus,
1314
WalletDeviceStatus,
@@ -441,11 +442,16 @@ async def get_wallet(
441442
wallet_id: UUID,
442443
db: AsyncSession,
443444
) -> models_myeclpay.Wallet | None:
444-
result = await db.execute(
445-
select(models_myeclpay.Wallet).where(
445+
# We lock the wallet `for update` to prevent race conditions
446+
request = (
447+
select(models_myeclpay.Wallet)
448+
.where(
446449
models_myeclpay.Wallet.id == wallet_id,
447-
),
450+
)
451+
.with_for_update(of=models_myeclpay.Wallet)
448452
)
453+
454+
result = await db.execute(request)
449455
return result.scalars().first()
450456

451457

@@ -512,11 +518,23 @@ async def increment_wallet_balance(
512518
"""
513519
Append `amount` to the wallet balance.
514520
"""
515-
await db.execute(
516-
update(models_myeclpay.Wallet)
521+
# Prevent a race condition by locking the wallet row
522+
# as we don't want the balance to be modified between the select and the update.
523+
request = (
524+
select(models_myeclpay.Wallet)
517525
.where(models_myeclpay.Wallet.id == wallet_id)
518-
.values(balance=models_myeclpay.Wallet.balance + amount),
526+
.options(
527+
noload(models_myeclpay.Wallet.store),
528+
noload(models_myeclpay.Wallet.user),
529+
)
530+
.with_for_update()
519531
)
532+
result = await db.execute(request)
533+
wallet = result.scalars().first()
534+
535+
if wallet is None:
536+
raise WalletNotFoundOnUpdateError(wallet_id=wallet_id)
537+
wallet.balance += amount
520538

521539

522540
async def create_user_payment(
@@ -600,12 +618,16 @@ async def get_transaction(
600618
transaction_id: UUID,
601619
db: AsyncSession,
602620
) -> schemas_myeclpay.Transaction | None:
621+
# We lock the transaction `for update` to prevent
622+
# race conditions
603623
result = (
604624
(
605625
await db.execute(
606-
select(models_myeclpay.Transaction).where(
626+
select(models_myeclpay.Transaction)
627+
.where(
607628
models_myeclpay.Transaction.id == transaction_id,
608-
),
629+
)
630+
.with_for_update(of=models_myeclpay.Transaction),
609631
)
610632
)
611633
.scalars()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from uuid import UUID
2+
3+
4+
class WalletNotFoundOnUpdateError(Exception):
5+
"""
6+
Exception raised when a wallet is not found during an update operation.
7+
This should lead to an internal server error response and a rollback of the transaction.
8+
"""
9+
10+
def __init__(self, wallet_id: UUID):
11+
super().__init__(f"Wallet {wallet_id} not found when updating")

app/core/myeclpay/models_myeclpay.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class WalletDevice(Base):
3838
id: Mapped[PrimaryKey]
3939
name: Mapped[str]
4040
wallet_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_wallet.id"))
41-
ed25519_public_key: Mapped[bytes]
41+
ed25519_public_key: Mapped[bytes] = mapped_column(unique=True)
4242
creation: Mapped[datetime]
4343
status: Mapped[WalletDeviceStatus]
4444
activation_token: Mapped[str] = mapped_column(unique=True)
@@ -87,7 +87,10 @@ class Refund(Base):
8787
__tablename__ = "myeclpay_refund"
8888

8989
id: Mapped[PrimaryKey]
90-
transaction_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_transaction.id"))
90+
transaction_id: Mapped[UUID] = mapped_column(
91+
ForeignKey("myeclpay_transaction.id"),
92+
unique=True,
93+
)
9194
debited_wallet_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_wallet.id"))
9295
credited_wallet_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_wallet.id"))
9396
total: Mapped[int] # Stored in cents
@@ -137,7 +140,7 @@ class StructureManagerTransfert(Base):
137140
primary_key=True,
138141
)
139142
user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id"))
140-
confirmation_token: Mapped[str]
143+
confirmation_token: Mapped[str] = mapped_column(unique=True)
141144
valid_until: Mapped[datetime]
142145

143146

@@ -170,6 +173,7 @@ class Request(Base):
170173
status: Mapped[RequestStatus]
171174
transaction_id: Mapped[UUID | None] = mapped_column(
172175
ForeignKey("myeclpay_transaction.id"),
176+
unique=True,
173177
)
174178

175179

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""empty message
2+
3+
Create Date: 2025-05-29 17:32:45.619972
4+
"""
5+
6+
from collections.abc import Sequence
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from pytest_alembic import MigrationContext
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "ea26eebe3f8d"
17+
down_revision: str | None = "e16b58cc6084"
18+
branch_labels: str | Sequence[str] | None = None
19+
depends_on: str | Sequence[str] | None = None
20+
21+
22+
def upgrade() -> None:
23+
op.create_unique_constraint(
24+
"myeclpay_refund_transaction_id_key",
25+
"myeclpay_refund",
26+
["transaction_id"],
27+
)
28+
op.create_unique_constraint(
29+
"myeclpay_request_transaction_id_key",
30+
"myeclpay_request",
31+
["transaction_id"],
32+
)
33+
op.create_unique_constraint(
34+
"myeclpay_structure_manager_transfer_confirmation_token_key",
35+
"myeclpay_structure_manager_transfer",
36+
["confirmation_token"],
37+
)
38+
op.create_unique_constraint(
39+
"myeclpay_wallet_device_ed25519_public_key_key",
40+
"myeclpay_wallet_device",
41+
["ed25519_public_key"],
42+
)
43+
44+
45+
def downgrade() -> None:
46+
op.drop_constraint(
47+
constraint_name="myeclpay_refund_transaction_id_key",
48+
table_name="myeclpay_refund",
49+
type_="unique",
50+
)
51+
op.drop_constraint(
52+
constraint_name="myeclpay_request_transaction_id_key",
53+
table_name="myeclpay_request",
54+
type_="unique",
55+
)
56+
op.drop_constraint(
57+
constraint_name="myeclpay_structure_manager_transfer_confirmation_token_key",
58+
table_name="myeclpay_structure_manager_transfer",
59+
type_="unique",
60+
)
61+
op.drop_constraint(
62+
constraint_name="myeclpay_wallet_device_ed25519_public_key_key",
63+
table_name="myeclpay_wallet_device",
64+
type_="unique",
65+
)
66+
67+
68+
def pre_test_upgrade(
69+
alembic_runner: "MigrationContext",
70+
alembic_connection: sa.Connection,
71+
) -> None:
72+
pass
73+
74+
75+
def test_upgrade(
76+
alembic_runner: "MigrationContext",
77+
alembic_connection: sa.Connection,
78+
) -> None:
79+
pass

tests/test_myeclpay.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,13 +1518,16 @@ async def test_create_and_activate_user_device(
15181518
return_value=UNIQUE_TOKEN,
15191519
)
15201520

1521+
private_key = Ed25519PrivateKey.generate()
1522+
public_key = private_key.public_key()
1523+
15211524
response = client.post(
15221525
"/myeclpay/users/me/wallet/devices",
15231526
headers={"Authorization": f"Bearer {ecl_user_access_token}"},
15241527
json={
15251528
"name": "MySuperDevice",
15261529
"ed25519_public_key": base64.b64encode(
1527-
ecl_user_wallet_device_public_key.public_bytes(
1530+
public_key.public_bytes(
15281531
encoding=serialization.Encoding.Raw,
15291532
format=serialization.PublicFormat.Raw,
15301533
),
@@ -1540,12 +1543,9 @@ async def test_create_and_activate_user_device(
15401543
response.json()["id"],
15411544
)
15421545
assert wallet_device is not None
1543-
assert (
1544-
wallet_device.ed25519_public_key
1545-
== ecl_user_wallet_device_public_key.public_bytes(
1546-
encoding=serialization.Encoding.Raw,
1547-
format=serialization.PublicFormat.Raw,
1548-
)
1546+
assert wallet_device.ed25519_public_key == public_key.public_bytes(
1547+
encoding=serialization.Encoding.Raw,
1548+
format=serialization.PublicFormat.Raw,
15491549
)
15501550

15511551
response = client.get(
@@ -1628,7 +1628,7 @@ async def test_revoke_user_device(
16281628
id=uuid4(),
16291629
name="Will revoke device",
16301630
wallet_id=ecl_user_wallet.id,
1631-
ed25519_public_key=b"key",
1631+
ed25519_public_key=b"keytest_revoke_user_device",
16321632
creation=datetime.now(UTC),
16331633
status=WalletDeviceStatus.ACTIVE,
16341634
activation_token="will_revoke_activation_token",

0 commit comments

Comments
 (0)