MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3717

(motir-core) Two concurrent first-workspace creates give ONE user TWO free orgs — the §4.5 org-creation gate takes no lock at all

Done
Description

Type · code · Parent · MOTIR-3413 (discovery epic; no dependency edge into the finding card, so the epic is the container per log-bug.md's edge test) · Discovered in · MOTIR-3710, during that card's mandated audit of the sibling caps · Resolution · open

Root cause

entitlementsService.assertCanCreateOrganization (lib/services/entitlementsService.ts:150-176) is the §4.5 org-creation gate. It is the ONE cap in that file that takes no row lock at all:

const orgs = await organizationMembershipRepository.findOwnerAdminOrgsWithSubscription(actorUserId, tx);
if (orgs.length === 0) return;              // the first org — always free
const hasUncappedOrg = orgs.some((o) => o.isMeta || isScaledActive(...));
if (!hasUncappedOrg) throw new EntitlementExceededError('organizations', { limit: orgs.length });

That is a count -> compare -> create with nothing serializing it. Two concurrent creates by a user who owns zero orgs both read orgs.length === 0, both take the always-free early return, and both create an org. The user ends with two free orgs having never owned a paid one — the exact dodge the method's own comment says it exists to prevent ("otherwise a free account could spin up N free orgs to dodge the per-org caps").

This is a DIFFERENT shape from MOTIR-3710, which is why it is a separate card rather than part of it. There the org-row FOR UPDATE existed and was inert under RLS; here there is no lock to arm. MOTIR-3710's fix does not touch this method and cannot: the gate reads the ACTOR's memberships, so there is no single shared org row to lock — the anchor would have to be the user row, or a SELECT ... FOR UPDATE over the actor's organization_membership rows, which is an EMPTY SET on the very first create and so serializes nobody.

Reproduced, at production altitude — not read off the code

tests/entitlementsService.test.ts's fixtures, MOTIR_CLOUD=true, on origin/main @ f305d821f plus MOTIR-3710's branch (which does not touch this method). Two workspacesService.createWorkspace calls for one fresh user, raced with Promise.allSettledno barrier needed; they interleave naturally, because each opens its own transaction and does async slug work before reaching the gate:

{ "fulfilled": 2, "rejected": [], "orgMembershipsForUser": 2, "distinctOrgs": 2 }

Both fulfilled. Zero rejections. The user holds two organizations.

Blast radius, stated honestly

Bounded at TWO, not N. Once the user owns one org, every subsequent racer reads orgs.length === 1 with hasUncappedOrg === false and both correctly reject — so the window is only the 0 -> 2 transition. Two free orgs is still two full free-tier allowances (2 x 250 work items, 2 x 3 projects, 2 x 1 workspace) for an account entitled to one, and it is reachable from a double-submitted signup or any client that fires two creates.

Cloud-only: every method in the file returns early when isCloudBilling() is false, so a self-hosted build is unaffected.

Fix direction

  1. Serialize on a row the actor genuinely owns. The natural anchor is the user row — SELECT "id" FROM "user" WHERE "id" = $1 FOR UPDATE inside the same transaction, before the membership count. Whatever is chosen, it must be a row that EXISTS at orgs.length === 0; the membership set does not.
  2. Read the lock's result and refuse when it matched nothing — the lockOrgRowOrRefuse shape MOTIR-3710 introduced for the three count caps. A gate that cannot serialize must refuse, not admit.
  3. The calling context must ADMIT the lock. MOTIR-3710's finding applies verbatim: a SELECT ... FOR UPDATE is filtered by the UPDATE policy's USING clause, so check user's policy set for an arm the acting-user context binds — and prove it with a probe returning true, not by reading the policy.

Acceptance criteria

  • A probe of whatever row lock the fix introduces, executed in the context assertCanCreateOrganization actually runs in, returns true, and the pull-request body quotes it.
  • A real-concurrency test races two first-org creates for one fresh user and asserts the user ends with exactly ONE organization membership; it FAILS on unmodified code with orgMembershipsForUser: 2, and both outputs are quoted in the pull-request body.
  • The gate REFUSES rather than proceeding when its lock matches no row; a test covers that arm directly.
  • The gate stays INERT off-cloud — a test asserts a self-hosted build can still create a second org.
  • pnpm vitest run tests/entitlementsService.test.ts passes and the pull request's Vitest shard is green.

Context refs

  • lib/services/entitlementsService.ts:150-176assertCanCreateOrganization, the unlocked gate
  • lib/services/workspacesService.ts:146-170insertWorkspaceWithOwner, the mint-own-org branch that calls it
  • lib/services/organizationsService.tscreateOrganization, the other caller
  • lib/repositories/organizationMembershipRepository.tsfindOwnerAdminOrgsWithSubscription, the unserialized read
  • MOTIR-3710 — the sibling defect, its lockOrgRowOrRefuse helper, and the RLS-arm finding this fix must re-check for user