Invitations: spending one is not conditional, so parallel sign-ups on PostgreSQL can open several accounts #544

Closed
opened 2026-10-01 10:47:31 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading how an invitation is checked at sign-up and spent afterwards.

Checked again on 2026-10-01 against main at 0a5cad587: still present.

Sign-up is allowed on a closed instance when the session holds an invitation that pending_invite() finds unspent. The invitation is spent afterwards, in the user_signed_up receiver, by Invite.accept(), which sets accepted_at and accepted_by and saves unconditionally. Nothing makes the check and the spend one step, so requests that overlap can each pass the check and each create an account.

  • Where:
    • accounts/adapter.py:19-25, pending_invite, and :50-53, AccountAdapter.is_open_for_signup: the check.
    • accounts/signals.py:64-78, consume_invite: the spend, after the account exists.
    • accounts/models.py:644-648, Invite.accept: self.save(update_fields=["accepted_at", "accepted_by"]), with no accepted_at IS NULL condition.
  • What happens: on PostgreSQL each request is its own READ COMMITTED transaction. Several sign-up submissions sent at the same moment from the session that followed one invitation all read it as unspent before any commits, each creates an account, and each accept overwrites accepted_by with its own user. With an invitation bound to an address, clean_email holds every account to that address; with a link-only invitation the addresses can differ. On a file SQLite database requests are serialised by the immediate transaction, and the second submission finds the invitation spent.
  • Who can trigger it: the holder of a valid invitation, who is entitled to one account. Needs PostgreSQL.
  • Why it matters: an invitation is the operator's decision to let one more person in; it can yield several accounts on an instance whose registration is closed, and the invitation list then names only the last of them.
  • Fix: spend the invitation with a conditional update inside the sign-up transaction, Invite.objects.filter(pk=invite.pk, accepted_at__isnull=True, expires_at__gt=now).update(accepted_at=now, accepted_by=user), and when it changes no row raise so the new account is rolled back; or take the invitation with select_for_update() in is_open_for_signup / clean_email so a second sign-up waits and then finds it spent.

Done when

  • tests/test_invites.py: after one sign-up has spent an invitation, a second consume_invite for a different user with the same (stale) invitation object fails and leaves accepted_by unchanged; the conditional update is asserted by calling accept twice on two copies of one invitation.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading how an invitation is checked at sign-up and spent afterwards. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. Sign-up is allowed on a closed instance when the session holds an invitation that `pending_invite()` finds unspent. The invitation is spent afterwards, in the `user_signed_up` receiver, by `Invite.accept()`, which sets `accepted_at` and `accepted_by` and saves unconditionally. Nothing makes the check and the spend one step, so requests that overlap can each pass the check and each create an account. - **Where:** - `accounts/adapter.py:19-25`, `pending_invite`, and `:50-53`, `AccountAdapter.is_open_for_signup`: the check. - `accounts/signals.py:64-78`, `consume_invite`: the spend, after the account exists. - `accounts/models.py:644-648`, `Invite.accept`: `self.save(update_fields=["accepted_at", "accepted_by"])`, with no `accepted_at IS NULL` condition. - **What happens:** on PostgreSQL each request is its own READ COMMITTED transaction. Several sign-up submissions sent at the same moment from the session that followed one invitation all read it as unspent before any commits, each creates an account, and each `accept` overwrites `accepted_by` with its own user. With an invitation bound to an address, `clean_email` holds every account to that address; with a link-only invitation the addresses can differ. On a file SQLite database requests are serialised by the immediate transaction, and the second submission finds the invitation spent. - **Who can trigger it:** the holder of a valid invitation, who is entitled to one account. Needs PostgreSQL. - **Why it matters:** an invitation is the operator's decision to let one more person in; it can yield several accounts on an instance whose registration is closed, and the invitation list then names only the last of them. - **Fix:** spend the invitation with a conditional update inside the sign-up transaction, `Invite.objects.filter(pk=invite.pk, accepted_at__isnull=True, expires_at__gt=now).update(accepted_at=now, accepted_by=user)`, and when it changes no row raise so the new account is rolled back; or take the invitation with `select_for_update()` in `is_open_for_signup` / `clean_email` so a second sign-up waits and then finds it spent. ## Done when - `tests/test_invites.py`: after one sign-up has spent an invitation, a second `consume_invite` for a different user with the same (stale) invitation object fails and leaves `accepted_by` unchanged; the conditional update is asserted by calling `accept` twice on two copies of one invitation.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:47:31 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#544
No description provided.