From d7a9d2bfba5b796882d3e04be6038b766cd0a4c8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 16:05:23 +0300 Subject: [PATCH] fix: reassign orphaned records on merge, eliminate TOCTOU race - Reassign SubscriptionConversion, SubscriptionEvent, DiscountOffer from secondary to primary during merge (previously orphaned) - Consume-first pattern: atomically GETDEL merge token before validation, restore on invalid input (eliminates TOCTOU window) --- app/cabinet/routes/account_linking.py | 35 +++++++++++---------------- app/services/account_merge_service.py | 14 +++++++++++ 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/app/cabinet/routes/account_linking.py b/app/cabinet/routes/account_linking.py index 41ca09b4..dfa28df3 100644 --- a/app/cabinet/routes/account_linking.py +++ b/app/cabinet/routes/account_linking.py @@ -431,27 +431,7 @@ async def execute_merge_endpoint( db: AsyncSession = Depends(get_cabinet_db), ) -> MergeResponse: """Execute account merge. Consumes the merge token (one-time use).""" - # 1. Read token data first (non-destructive) to validate request - token_data = await get_merge_token_data(merge_token) - if not token_data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail='Merge token is invalid, expired, or already consumed', - ) - - primary_user_id: int = token_data['primary_user_id'] - secondary_user_id: int = token_data['secondary_user_id'] - provider: str = token_data.get('provider', '') - provider_id: str = token_data.get('provider_id', '') - - # 2. Validate keep_subscription_from BEFORE consuming token - if request.keep_subscription_from not in (primary_user_id, secondary_user_id): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='keep_subscription_from must be one of the two user IDs being merged', - ) - - # 3. Consume token atomically (one-time use) + # 1. Consume token atomically first (GETDEL — one-time use, no TOCTOU) consumed = await consume_merge_token(merge_token) if not consumed: raise HTTPException( @@ -459,6 +439,19 @@ async def execute_merge_endpoint( detail='Merge token is invalid, expired, or already consumed', ) + primary_user_id: int = consumed['primary_user_id'] + secondary_user_id: int = consumed['secondary_user_id'] + provider: str = consumed.get('provider', '') + provider_id: str = consumed.get('provider_id', '') + + # 2. Validate keep_subscription_from — restore token if invalid + if request.keep_subscription_from not in (primary_user_id, secondary_user_id): + await restore_merge_token(merge_token, consumed) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='keep_subscription_from must be one of the two user IDs being merged', + ) + # Convert user_id to 'primary'/'secondary' string for execute_merge() keep_from: Literal['primary', 'secondary'] = 'primary' if request.keep_subscription_from == primary_user_id else 'secondary' diff --git a/app/services/account_merge_service.py b/app/services/account_merge_service.py index 9e3f7091..339feb63 100644 --- a/app/services/account_merge_service.py +++ b/app/services/account_merge_service.py @@ -13,6 +13,7 @@ from app.database.models import ( CabinetRefreshToken, CloudPaymentsPayment, CryptoBotPayment, + DiscountOffer, FreekassaPayment, HeleketPayment, KassaAiPayment, @@ -22,6 +23,8 @@ from app.database.models import ( PlategaPayment, ReferralEarning, Subscription, + SubscriptionConversion, + SubscriptionEvent, Transaction, User, UserStatus, @@ -456,6 +459,17 @@ async def execute_merge( update(WithdrawalRequest).where(WithdrawalRequest.user_id == secondary.id).values(user_id=primary.id) ) + # 10a. Переназначение subscription_conversions, subscription_events, discount_offers + await db.execute( + update(SubscriptionConversion).where(SubscriptionConversion.user_id == secondary.id).values(user_id=primary.id) + ) + await db.execute( + update(SubscriptionEvent).where(SubscriptionEvent.user_id == secondary.id).values(user_id=primary.id) + ) + await db.execute( + update(DiscountOffer).where(DiscountOffer.user_id == secondary.id).values(user_id=primary.id) + ) + # 11. Инвалидация refresh-токенов обоих пользователей (после мержа будет создан новый) now = datetime.now(UTC) await db.execute(