Skip to content

Commit 7b4e737

Browse files
authored
fix(workspaces): gate workspace creation before the transaction, not inside it (#7493)
* fix(workspaces): gate workspace creation before the transaction, not inside it `lockWorkspaceCreationContext` called `isOrganizationCapabilityWithheld` with no executor, so it ran on the global `db` pool while the creation transaction already held a pooled connection and three advisory locks. That is the pool deadlock `packages/db/tx-tripwire.ts` exists to detect — it is firing in staging right now, four times per request, 144 lines across 36 requests in six hours, all on POST /api/workspaces. It also added up to four SEQUENTIAL reads to a lock hold that serializes every organization mutation, which is what pushed concurrent creates past the 5s lock_timeout and answered them as a generic 500. Nothing is given up by moving the gate out. The re-read was never serialized against permission-group writes: those take `permission_group:<org>` while creation takes `organization-mutation:<org>`, which are different advisory-lock ids and never contend. What the check actually provides is recency against the caller's earlier preflight, and that holds identically microseconds earlier. The governing organization is `organizationId ?? observedOrganizationId`, both known before the transaction, and `lockWorkspaceCreationContext` still refuses to commit unless live membership still equals `observedOrganizationId` — so a verdict computed outside can never be applied to a different organization. The comment justifying the old placement was also wrong: for POST /api/workspaces the preflight and the insert are the same request, not separate ones. Rejected alternatives: threading an executor through the resolver would touch seven functions across four files, requires exporting the uncached `resolveOrganizationEnterprisePlan`, and bypassing the React cache is a fail-OPEN semantic change. `runOutsideTransactionContext` only exits the ambient context — it would silence the tripwire while leaving the second pooled connection checkout in place. Adds create.test.ts pinning the ordering, because no existing test can catch a regression here: vitest.setup.ts mocks @sim/db globally, so the real pool instrumentation never runs and the tripwire cannot fire in any apps/sim test. * fix(workspaces): reattach the lock helper's TSDoc and correct it The new gate was inserted between `lockWorkspaceCreationContext`'s doc block and the function, orphaning the doc onto the gate and leaving the lock helper undocumented. The doc was also stale: it described serializing 'the final creation-policy check', which no longer happens there. It now states what the function does enforce — the membership invariant the hoisted gate's verdict depends on. * fix(workspaces): serialize the workspace.create gate against permission-group writes The interim fix hoisted the whole capability gate out of the creation transaction. That removed the second pooled-connection checkout the `DbTxTripwire` fires on, but left the revocation window open: an admin could revoke `workspace.create` between the gate and the insert. The original placement did not close it either — creation took `organization-mutation:<org>` while permission-group writes take `permission_group:<org>`, so the two never contended. Split the decision along the line that actually matters: - ENTITLEMENT (is this organization governed by permission groups at all) stays outside the transaction. It bottoms out in `isOrganizationOnEnterprisePlan`, which is `cache(resolveOrganizationEnterprisePlan)` and admits no options object by design — an executor argument would miss the memo on every call, and bypassing the cache resolves a lapsed read to `config: null`, meaning every capability ALLOWED. `permission_group:<org>` never serialized subscription changes, so holding it across this read excluded nothing. A concurrent lapse now applies the group config for one more request (refuses, does not permit); a concurrent grant skips it for one more request, the same answer the route's own preflight gave microseconds earlier. - The CAPABILITY itself is re-read INSIDE the transaction, on the transaction executor, under `acquirePermissionGroupOrgLock` — the same advisory lock every permission-group mutation takes. That is what makes the check-to-insert window closed rather than merely narrow. `resolveDefaultGroup` gains an executor parameter; nothing else in the chain is threaded. The pre-transaction capability read is REMOVED rather than kept as a fast-fail: both callers of `createWorkspace` already refuse on `creationPolicy.canCreate` (`app/api/workspaces/route.ts`), so it re-read the same value microseconds later and caught only what the under-lock read now catches definitively. Net effect is one fewer read on the enterprise create path, not one more. LOCK ORDER: `organization-mutation:<org>` -> `user-billing-identity:<user>` -> `<user>:<org>` -> `permission_group:<org>`. The new lock is taken last, and safely so: it is a leaf. Every transaction that holds it — the five `organizations/[id]/permission-groups` route transactions — acquires no further advisory lock afterwards, so no holder can be waiting on any of the three above it and no cycle can form. It is also taken only AFTER live membership is confirmed, so a caller who turns out not to belong to the organization never serializes against its admins, and only when the regime is active, so non-enterprise and unaffiliated creators take nothing new. `createDefaultPersonalWorkspaceInTransaction`, which runs inside the enterprise owner claim's external transaction, passes both organization ids as `null`, so it is ungoverned by construction, takes no permission-group lock, and cannot deadlock against the locks that transaction already holds. `acquirePermissionGroupOrgLock` moves to `lib/permission-groups/locks.ts` so `lib/workspaces/policy.ts` can acquire it without importing from `app/api/**`; the five route call sites are repointed rather than re-exported. Contention this adds, honestly: workspace creates in an Enterprise organization with Access Control now serialize against that organization's permission-group admin writes, and against each other, on one org-wide key. Personal creates by members of such an organization take that lock where they previously took none. Permission-group writes are low-frequency admin actions and the lock is held for one indexed single-row read, so the expected wait is negligible — but it is a real new serialization point, not a free one. * fix(workspaces): carry the governing organization instead of a derived boolean Replaces `permissionGroupsGovernCreation: boolean` plus a separately re-derived organization id with the organization id itself, so the capability gate has one field carrying one fact and no branch TypeScript cannot see through. - `resolveGoverningPermissionGroupOrganization` replaces both `isWorkspaceCreationGovernedByPermissionGroups` and `governingOrganizationIdFor`; the id is derived once, before the transaction, and reused inside it. - The creation policy now carries the resolved organization, so the create route does not re-issue the entitlement read that React's `cache()` memo does not span in an App Route. - `lockWorkspaceCreationContext` takes the permission-group lock after the organization's own `FOR UPDATE` revalidation and owner lookup, so an org-wide key is no longer held across a blocking row-lock wait. - `resolveDefaultGroup` and `getEntitledOrganizationPermissionConfig` require their executor, so a caller cannot silently reintroduce the second pooled-connection checkout. - `acquirePermissionGroupOrgLock` skips the redundant `lock_timeout` `set_config` when the caller already bounded it at the same value. * docs(workspaces): cut the third copy of the personal-workspace rationale The escape-hatch reasoning already lives at the preflight that enforces it, and again in the test. A pointer is enough here.
1 parent aafe514 commit 7b4e737

13 files changed

Lines changed: 754 additions & 132 deletions

File tree

apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints'
14+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1415
import {
15-
acquirePermissionGroupOrgLock,
1616
authorizeOrgAccessControl,
1717
findScopeConflicts,
1818
formatScopeConflictError,

apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints'
14+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1415
import { isOrganizationMember } from '@/lib/workspaces/permissions/utils'
1516
import {
1617
type AllMembersConflict,
17-
acquirePermissionGroupOrgLock,
1818
authorizeOrgAccessControl,
1919
findAllMembersWorkspaceConflict,
2020
findScopeConflicts,

apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
type PermissionGroupConfig,
1616
parsePermissionGroupConfig,
1717
} from '@/lib/permission-groups/fields'
18+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1819
import {
1920
type AllMembersConflict,
20-
acquirePermissionGroupOrgLock,
2121
authorizeOrgAccessControl,
2222
findAllMembersWorkspaceConflict,
2323
findScopeConflicts,

apps/sim/app/api/organizations/[id]/permission-groups/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import {
2121
type PermissionGroupConfig,
2222
parsePermissionGroupConfig,
2323
} from '@/lib/permission-groups/fields'
24+
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
2425
import {
2526
type AllMembersConflict,
26-
acquirePermissionGroupOrgLock,
2727
authorizeOrgAccessControl,
2828
findAllMembersWorkspaceConflict,
2929
findWorkspacesNotInOrganization,

apps/sim/app/api/organizations/[id]/permission-groups/utils.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,36 +41,6 @@ export async function authorizeOrgAccessControl(
4141
return null
4242
}
4343

44-
const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
45-
46-
/**
47-
* Serialize all permission-group membership and scope writes for an organization
48-
* via a transaction-scoped Postgres advisory lock. Callers acquire it at the top
49-
* of the transaction that both checks (`findScopeConflicts`) and mutates, so a
50-
* concurrent member add or scope change can't commit in the check-to-write
51-
* window and leave a user governed by two groups on the same workspace.
52-
*
53-
* The invariant (one effective group per user per workspace) spans users and
54-
* groups in ways a unique constraint can't express, and these are low-frequency
55-
* admin writes, so a single org-scoped lock is simpler and more obviously
56-
* correct than fine-grained per-user/per-group locks with acquire-ordering.
57-
*
58-
* `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
59-
* connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
60-
* instead of hanging if a holder is stuck.
61-
*/
62-
export async function acquirePermissionGroupOrgLock(
63-
tx: DbOrTx,
64-
organizationId: string
65-
): Promise<void> {
66-
await tx.execute(
67-
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
68-
)
69-
await tx.execute(
70-
sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
71-
)
72-
}
73-
7444
/** Load a permission group only if it belongs to the given organization. */
7545
export async function loadGroupInOrganization(
7646
groupId: string,

apps/sim/app/api/workspaces/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { type WorkspaceMode, workflow } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { getPostgresErrorCode } from '@sim/utils/errors'
56
import { and, eq, isNull } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
@@ -157,6 +158,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
157158
workspaceMode: creationPolicy.workspaceMode,
158159
billedAccountUserId: creationPolicy.billedAccountUserId,
159160
observedOrganizationId: creationPolicy.observedOrganizationId,
161+
governingPermissionGroupOrganizationId: creationPolicy.governingPermissionGroupOrganizationId,
160162
})
161163

162164
captureServerEvent(
@@ -207,6 +209,20 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
207209
{ status: 409 }
208210
)
209211
}
212+
/**
213+
* A lock timeout is contention, not a fault: creation serializes on the
214+
* organization's mutation locks and now also on `permission_group:<org>`,
215+
* so a concurrent create or a permission-group admin write can exhaust the
216+
* `lock_timeout` and abort this transaction. Answer 503 like the
217+
* permission-group routes do, rather than letting it reach the generic 500
218+
* below — the caller should retry, and a 500 tells them the opposite.
219+
*/
220+
if (getPostgresErrorCode(error) === '55P03') {
221+
return NextResponse.json(
222+
{ error: 'This organization is being updated by another request. Please try again.' },
223+
{ status: 503 }
224+
)
225+
}
210226
logger.error('Error creating workspace:', error)
211227
return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 })
212228
}
@@ -220,6 +236,7 @@ async function createDefaultWorkspace(
220236
workspaceMode: WorkspaceMode
221237
billedAccountUserId: string
222238
observedOrganizationId: string | null
239+
governingPermissionGroupOrganizationId: string | null
223240
}
224241
) {
225242
const firstName = userName?.split(' ')[0] || null
@@ -231,6 +248,7 @@ async function createDefaultWorkspace(
231248
workspaceMode: creationPolicy.workspaceMode,
232249
billedAccountUserId: creationPolicy.billedAccountUserId,
233250
observedOrganizationId: creationPolicy.observedOrganizationId,
251+
governingPermissionGroupOrganizationId: creationPolicy.governingPermissionGroupOrganizationId,
234252
})
235253
}
236254

apps/sim/lib/permission-groups/capability-assertions.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import type { DbOrTx } from '@/lib/db/types'
12
import {
23
CAPABILITY_RULES,
34
refuseCapability,
45
type StaticPermissionGroupCapability,
56
} from '@/lib/permission-groups/capabilities'
67
import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server'
78
import type { PermissionGroupConfig } from '@/lib/permission-groups/fields'
8-
import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server'
9+
import {
10+
getEntitledOrganizationPermissionConfig,
11+
getUserPermissionConfigForOrganization,
12+
} from '@/lib/permission-groups/resolve.server'
913

1014
/**
1115
* Re-exported so a caller that gates inline reaches the refusal sentence and the
@@ -90,3 +94,20 @@ export async function isOrganizationCapabilityWithheld(
9094
await getUserPermissionConfigForOrganization(organizationId)
9195
)
9296
}
97+
98+
/**
99+
* {@link isOrganizationCapabilityWithheld} for an organization whose regime the
100+
* caller has ALREADY established, reading the group on the given executor. That
101+
* establishment is the caller's obligation, not an optional one — see
102+
* {@link getEntitledOrganizationPermissionConfig}.
103+
*/
104+
export async function isEntitledOrganizationCapabilityWithheld(
105+
organizationId: string,
106+
capability: StaticPermissionGroupCapability,
107+
executor: DbOrTx
108+
): Promise<boolean> {
109+
return capabilityDeniedBy(
110+
capability,
111+
await getEntitledOrganizationPermissionConfig(organizationId, executor)
112+
)
113+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { sql } from 'drizzle-orm'
2+
import type { DbOrTx } from '@/lib/db/types'
3+
4+
const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
5+
6+
/**
7+
* Serialize all permission-group membership, scope, and config writes for an
8+
* organization via a transaction-scoped Postgres advisory lock. Callers acquire
9+
* it at the top of the transaction that both checks (`findScopeConflicts`) and
10+
* mutates, so a concurrent member add or scope change can't commit in the
11+
* check-to-write window and leave a user governed by two groups on the same
12+
* workspace.
13+
*
14+
* The invariant (one effective group per user per workspace) spans users and
15+
* groups in ways a unique constraint can't express, and these are low-frequency
16+
* admin writes, so a single org-scoped lock is simpler and more obviously
17+
* correct than fine-grained per-user/per-group locks with acquire-ordering.
18+
*
19+
* Readers take it too, when the value they read decides whether a write in the
20+
* same transaction may commit — workspace creation re-reads the default group's
21+
* `workspace.create` capability under this lock, which is the only thing that
22+
* makes the check-to-insert window closed rather than merely narrow.
23+
*
24+
* `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
25+
* connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
26+
* instead of hanging if a holder is stuck. The key string is the contention
27+
* identity: any change to its format silently stops contending with in-flight
28+
* holders.
29+
*
30+
* `lockTimeoutAlreadyBounded` skips the `set_config` round trip for a caller
31+
* that has already bounded `lock_timeout` transaction-locally at the same
32+
* 5000ms — every advisory lock in `lib/billing/organizations/membership.ts`
33+
* does, and workspace creation takes those first. It stays a separate statement
34+
* rather than being folded into the `pg_advisory_xact_lock` select: target-list
35+
* evaluation order is unspecified, so the bound might not be in force when the
36+
* lock is requested.
37+
*
38+
* LOCK ORDER: this is a LEAF lock. Every transaction that holds it — the five
39+
* `organizations/[id]/permission-groups` route transactions, and the workspace
40+
* creation transaction — acquires no further advisory lock afterwards. That is
41+
* what makes it safe for workspace creation to take it *last*, after
42+
* `organization-mutation`, `user-billing-identity`, and the membership lock: a
43+
* deadlock needs a holder of this lock to wait on one of those, and no such
44+
* holder exists. Keep it a leaf.
45+
*
46+
* Lives in `lib/` rather than beside the routes because `lib/workspaces/policy.ts`
47+
* acquires it, and `lib/` must not import from `app/api/**`.
48+
*/
49+
export async function acquirePermissionGroupOrgLock(
50+
tx: DbOrTx,
51+
organizationId: string,
52+
options?: { lockTimeoutAlreadyBounded?: boolean }
53+
): Promise<void> {
54+
if (!options?.lockTimeoutAlreadyBounded) {
55+
await tx.execute(
56+
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
57+
)
58+
}
59+
await tx.execute(
60+
sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
61+
)
62+
}

apps/sim/lib/permission-groups/resolve.server.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
isAccessControlEnabled,
2323
isHosted,
2424
} from '@/lib/core/config/env-flags'
25+
import type { DbOrTx } from '@/lib/db/types'
2526
import {
2627
DEFAULT_PERMISSION_GROUP_CONFIG,
2728
type PermissionGroupConfig,
@@ -90,11 +91,19 @@ function inactiveUserAccessControlContext(organizationId: string | null): UserAc
9091
}
9192
}
9293

93-
/** The organization's single default group (`isDefault`), or `null`. */
94+
/**
95+
* The organization's single default group (`isDefault`), or `null`.
96+
*
97+
* The executor is required, not defaulted: a caller that must read the group
98+
* under `acquirePermissionGroupOrgLock` has to read it on the transaction's own
99+
* connection, and a default would let that caller silently check out a second
100+
* pooled connection while advisory locks are held.
101+
*/
94102
async function resolveDefaultGroup(
95-
organizationId: string
103+
organizationId: string,
104+
executor: DbOrTx
96105
): Promise<ResolvedPermissionGroup | null> {
97-
const [defaultGroup] = await db
106+
const [defaultGroup] = await executor
98107
.select({
99108
id: permissionGroup.id,
100109
name: permissionGroup.name,
@@ -181,7 +190,7 @@ export async function resolveWorkspaceGroup(
181190
}
182191
}
183192

184-
return resolveDefaultGroup(organizationId)
193+
return resolveDefaultGroup(organizationId, db)
185194
}
186195

187196
/**
@@ -281,16 +290,50 @@ export async function getUserPermissionConfig(
281290
export async function getUserPermissionConfigForOrganization(
282291
organizationId: string
283292
): Promise<PermissionGroupConfig | null> {
284-
if (!isHosted && !isAccessControlEnabled) {
293+
if (!(await isOrganizationPermissionRegimeActive(organizationId))) {
285294
return mergeEnvAllowlist(null)
286295
}
296+
return getEntitledOrganizationPermissionConfig(organizationId, db)
297+
}
287298

288-
/** `'throw'` for the same reason as in {@link resolveUserAccessControlContextForOrganization}. */
289-
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId, 'throw')
290-
if (!isEnterprise) {
291-
return mergeEnvAllowlist(null)
292-
}
299+
/**
300+
* Whether permission groups govern `organizationId` at all — the deployment
301+
* enables Access Control, and the organization holds the Enterprise entitlement
302+
* that turns the regime on.
303+
*
304+
* Split out of {@link getUserPermissionConfigForOrganization} so a caller that
305+
* must re-read the *group* under `acquirePermissionGroupOrgLock` can settle this
306+
* half BEFORE opening its transaction. The entitlement read cannot move into a
307+
* transaction: {@link isOrganizationOnEnterprisePlan} is `cache()`d on its
308+
* argument list, so it admits no executor, and giving it one would both miss the
309+
* memo on every call and — because an unentitled organization resolves to
310+
* `config: null`, meaning every capability ALLOWED — turn a read failure into a
311+
* fail-open. The lock never serialized this half either way: it guards
312+
* permission-group writes, not subscription changes.
313+
*
314+
* `'throw'` for the same reason as in
315+
* {@link resolveUserAccessControlContextForOrganization}.
316+
*/
317+
export async function isOrganizationPermissionRegimeActive(
318+
organizationId: string
319+
): Promise<boolean> {
320+
if (!isHosted && !isAccessControlEnabled) return false
321+
return isOrganizationOnEnterprisePlan(organizationId, 'throw')
322+
}
293323

294-
const resolved = await resolveDefaultGroup(organizationId)
324+
/**
325+
* The organization-level permission config for an organization already known to
326+
* be governed — the second half of {@link getUserPermissionConfigForOrganization},
327+
* callable on a transaction executor.
328+
*
329+
* Callers MUST have established {@link isOrganizationPermissionRegimeActive}
330+
* first; this function does not re-check entitlement, and reading it as though
331+
* it did would apply an unentitled organization's stale default group.
332+
*/
333+
export async function getEntitledOrganizationPermissionConfig(
334+
organizationId: string,
335+
executor: DbOrTx
336+
): Promise<PermissionGroupConfig | null> {
337+
const resolved = await resolveDefaultGroup(organizationId, executor)
295338
return mergeEnvAllowlist(resolved?.config ?? null)
296339
}

0 commit comments

Comments
 (0)