Invitations: spending one is not conditional, so parallel sign-ups on PostgreSQL can open several accounts #544
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#544
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
mainat0a5cad587: 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 theuser_signed_upreceiver, byInvite.accept(), which setsaccepted_atandaccepted_byand 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.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 noaccepted_at IS NULLcondition.acceptoverwritesaccepted_bywith its own user. With an invitation bound to an address,clean_emailholds 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.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 withselect_for_update()inis_open_for_signup/clean_emailso 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 secondconsume_invitefor a different user with the same (stale) invitation object fails and leavesaccepted_byunchanged; the conditional update is asserted by callingaccepttwice on two copies of one invitation.