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)
This commit is contained in:
Fringg
2026-03-04 16:05:23 +03:00
parent 531d5cff30
commit d7a9d2bfba
2 changed files with 28 additions and 21 deletions

View File

@@ -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'

View File

@@ -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(