Live organizations can lose a real-user owner record (set_owner to pending invite; delete race)

Summary

A live (not soft-deleted) organization's OrganizationOwner record should always point at a real user. Nothing enforces that. Two paths can break it:

1. set_owner accepts a pending invite. set_organization_owner (apps/organizations_ext/api.py) looks up the target by member id but doesn't reject members whose user is NULL (pending invites). achange_owner moves the owner record and leaves all roles unchanged. Effects:

  • Organization.email (the Stripe billing contact, apps/organizations_ext/models.py) reads owner.organization_user.user.email. That raises AttributeError, because only a missing owner record is caught. It breaks Stripe customer creation (apps/stripe/client.py) for orgs with no customer yet, and ThrottleNoticeEmail.
  • The previous owner no longer holds the owner record, so delete_user lets them delete their account. The member guard counts the invite as a member, so the delete succeeds. The result is a live org with no users, whose owner record points at an invite.
  • Whoever accepts that invite becomes the billing owner, with whatever role the invite carried, possibly member. See #517 for reusable invite links.

2. Race between set_owner and account deletion. delete_user calls adelete(), and Django's collector SELECTs before it DELETEs. If another owner moves the owner record onto the user in between, the cascade deletes the new OrganizationOwner. The org stays live with members but has no owner record. Organization.email falls back to the first owner-role user, but set_owner then fails reading organization.owner. This window predates !2621.

Possible direction

Add an owner guard modeled on the organization_has_members member guard (apps/organizations_ext/migrations/sql/organization_member_guard.sql). It would be a deferred constraint trigger on organizations_ext_organizationowner and organizationuser, enforcing that a live org has an owner record whose organization_user.user_id is not NULL. Soft-deleted orgs are exempt, and hard deletion passes because the org row is gone at commit. That covers both paths and any future one. Pair it with a friendly 400 in set_owner for pending members. A Python check alone fixes 1 but not 2.

Open question: should the owner record also require the owner role? Today a record holder can be demoted (overlaps #359 (closed) / #360 and glitchtip#42, which owns role semantics generally).

Related: #360 (prevent owner-less organizations), !2621 (account deletion after soft-deleting an owned org; first surfaced the NULL owner case).

AI disclosure: Claude Code (Claude Opus 5.5) found these while working on !2621, confirmed path 1's effects by reading the code, and drafted this issue. Path 2 was found by code review and not reproduced.

🤖 Generated with Claude Code