Skip to content

Commit 7a7a14e

Browse files
committed
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.
1 parent f066ede commit 7a7a14e

8 files changed

Lines changed: 332 additions & 222 deletions

File tree

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: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,9 @@ export async function isOrganizationCapabilityWithheld(
9797

9898
/**
9999
* {@link isOrganizationCapabilityWithheld} for an organization whose regime the
100-
* caller has ALREADY established with `isOrganizationPermissionRegimeActive`,
101-
* reading the group on the given executor.
102-
*
103-
* For the one caller that must answer this question inside the transaction that
104-
* commits the governed act, under `acquirePermissionGroupOrgLock`, so an admin
105-
* cannot revoke the capability in the check-to-write window. Splitting the
106-
* entitlement half off is what lets the remaining read run on the transaction's
107-
* own connection instead of checking out a second pooled one.
108-
*
109-
* Passing an executor without having checked entitlement would read a stale
110-
* default group as authoritative for an organization the regime no longer
111-
* governs, so that check is the caller's obligation, not an optional one.
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}.
112103
*/
113104
export async function isEntitledOrganizationCapabilityWithheld(
114105
organizationId: string,

apps/sim/lib/permission-groups/locks.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,17 @@ const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
2323
*
2424
* `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
2525
* connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
26-
* instead of hanging if a holder is stuck.
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.
2737
*
2838
* LOCK ORDER: this is a LEAF lock. Every transaction that holds it — the five
2939
* `organizations/[id]/permission-groups` route transactions, and the workspace
@@ -38,11 +48,14 @@ const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
3848
*/
3949
export async function acquirePermissionGroupOrgLock(
4050
tx: DbOrTx,
41-
organizationId: string
51+
organizationId: string,
52+
options?: { lockTimeoutAlreadyBounded?: boolean }
4253
): Promise<void> {
43-
await tx.execute(
44-
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
45-
)
54+
if (!options?.lockTimeoutAlreadyBounded) {
55+
await tx.execute(
56+
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
57+
)
58+
}
4659
await tx.execute(
4760
sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
4861
)

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,14 @@ function inactiveUserAccessControlContext(organizationId: string | null): UserAc
9494
/**
9595
* The organization's single default group (`isDefault`), or `null`.
9696
*
97-
* Takes an executor so a caller that must read the group under
98-
* `acquirePermissionGroupOrgLock` — inside the transaction that holds it — can
99-
* do so on the transaction's own connection instead of checking out a second
100-
* one from the pool.
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.
101101
*/
102102
async function resolveDefaultGroup(
103103
organizationId: string,
104-
executor: DbOrTx = db
104+
executor: DbOrTx
105105
): Promise<ResolvedPermissionGroup | null> {
106106
const [defaultGroup] = await executor
107107
.select({
@@ -190,7 +190,7 @@ export async function resolveWorkspaceGroup(
190190
}
191191
}
192192

193-
return resolveDefaultGroup(organizationId)
193+
return resolveDefaultGroup(organizationId, db)
194194
}
195195

196196
/**
@@ -293,7 +293,7 @@ export async function getUserPermissionConfigForOrganization(
293293
if (!(await isOrganizationPermissionRegimeActive(organizationId))) {
294294
return mergeEnvAllowlist(null)
295295
}
296-
return getEntitledOrganizationPermissionConfig(organizationId)
296+
return getEntitledOrganizationPermissionConfig(organizationId, db)
297297
}
298298

299299
/**
@@ -332,7 +332,7 @@ export async function isOrganizationPermissionRegimeActive(
332332
*/
333333
export async function getEntitledOrganizationPermissionConfig(
334334
organizationId: string,
335-
executor: DbOrTx = db
335+
executor: DbOrTx
336336
): Promise<PermissionGroupConfig | null> {
337337
const resolved = await resolveDefaultGroup(organizationId, executor)
338338
return mergeEnvAllowlist(resolved?.config ?? null)
Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,26 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import {
5+
dbChainMockFns,
6+
resetDbChainMock,
7+
workflowsPersistenceUtilsMock,
8+
workflowsPersistenceUtilsMockFns,
9+
} from '@sim/testing'
410
import { beforeEach, describe, expect, it, vi } from 'vitest'
511

612
const {
7-
mockTransaction,
8-
mockIsWorkspaceCreationGovernedByPermissionGroups,
13+
mockResolveGoverningPermissionGroupOrganization,
914
mockLockWorkspaceCreationContext,
1015
mockGetWorkspaceInvitePolicy,
11-
mockSaveWorkflowToNormalizedTables,
1216
} = vi.hoisted(() => ({
13-
mockTransaction: vi.fn(),
14-
mockIsWorkspaceCreationGovernedByPermissionGroups: vi.fn(),
17+
mockResolveGoverningPermissionGroupOrganization: vi.fn(),
1518
mockLockWorkspaceCreationContext: vi.fn(),
1619
mockGetWorkspaceInvitePolicy: vi.fn(),
17-
mockSaveWorkflowToNormalizedTables: vi.fn(),
18-
}))
19-
20-
vi.mock('@sim/db', () => ({
21-
db: { transaction: mockTransaction },
2220
}))
2321

2422
/** The starter workflow is not what these cases are about, and it reaches the block registry. */
25-
vi.mock('@/lib/workflows/persistence/utils', () => ({
26-
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
27-
}))
23+
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
2824

2925
vi.mock('@/lib/workflows/defaults', () => ({
3026
buildDefaultWorkflowArtifacts: () => ({ workflowState: {} }),
@@ -34,8 +30,7 @@ vi.mock('@/lib/workspaces/policy', async (importOriginal) => {
3430
const actual = await importOriginal<typeof import('@/lib/workspaces/policy')>()
3531
return {
3632
...actual,
37-
isWorkspaceCreationGovernedByPermissionGroups:
38-
mockIsWorkspaceCreationGovernedByPermissionGroups,
33+
resolveGoverningPermissionGroupOrganization: mockResolveGoverningPermissionGroupOrganization,
3934
lockWorkspaceCreationContext: mockLockWorkspaceCreationContext,
4035
getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy,
4136
}
@@ -60,29 +55,27 @@ const params = {
6055
describe('createWorkspace capability-gate placement', () => {
6156
beforeEach(() => {
6257
vi.clearAllMocks()
58+
resetDbChainMock()
6359
mockGetWorkspaceInvitePolicy.mockResolvedValue({})
6460
})
6561

6662
/**
67-
* The ENTITLEMENT half must be settled before the transaction opens: it
68-
* bottoms out in the `cache()`d `isOrganizationOnEnterprisePlan`, which admits
69-
* no executor, so running it inside checked out a SECOND pooled connection
70-
* while three advisory locks were held — what `packages/db/tx-tripwire.ts`
71-
* fires on, and what pushed concurrent creates past the 5s `lock_timeout`.
63+
* The ENTITLEMENT half must be settled before the transaction opens — see
64+
* {@link resolveGoverningPermissionGroupOrganization}.
7265
*
7366
* Asserted as an explicit ordering rather than inferred from the absence of a
7467
* tripwire warning: nothing else in the unit suite can catch a regression
7568
* here, because `vitest.setup.ts` mocks `@sim/db` globally and the real pool
7669
* instrumentation never runs.
7770
*/
7871
it('resolves the permission regime before opening the transaction', async () => {
79-
mockIsWorkspaceCreationGovernedByPermissionGroups.mockResolvedValue(true)
72+
mockResolveGoverningPermissionGroupOrganization.mockResolvedValue('org-1')
8073
/**
8174
* The callback is deliberately NOT invoked, so the transaction's own
8275
* internals stay out of the assertion and cannot fail it for an unrelated
8376
* reason.
8477
*/
85-
mockTransaction.mockResolvedValue({
78+
dbChainMockFns.transaction.mockResolvedValue({
8679
id: 'ws-1',
8780
name: params.name,
8881
organizationId: 'org-1',
@@ -93,27 +86,27 @@ describe('createWorkspace capability-gate placement', () => {
9386

9487
await createWorkspace(params)
9588

96-
expect(mockIsWorkspaceCreationGovernedByPermissionGroups).toHaveBeenCalledWith({
89+
expect(mockResolveGoverningPermissionGroupOrganization).toHaveBeenCalledWith({
9790
organizationId: 'org-1',
9891
observedOrganizationId: 'org-1',
9992
})
10093
expect(
101-
mockIsWorkspaceCreationGovernedByPermissionGroups.mock.invocationCallOrder[0]
102-
).toBeLessThan(mockTransaction.mock.invocationCallOrder[0])
94+
mockResolveGoverningPermissionGroupOrganization.mock.invocationCallOrder[0]
95+
).toBeLessThan(dbChainMockFns.transaction.mock.invocationCallOrder[0])
10396
})
10497

10598
/**
10699
* The capability itself is enforced INSIDE the transaction, under the
107-
* permission-group lock — so the regime answer has to reach
100+
* permission-group lock — so the governing organization has to reach
108101
* `lockWorkspaceCreationContext`. Dropping it there would silently skip the
109102
* gate for every governed organization.
110103
*/
111-
it('carries the regime answer into the locked creation context', async () => {
112-
mockIsWorkspaceCreationGovernedByPermissionGroups.mockResolvedValue(true)
104+
it('carries the governing organization into the locked creation context', async () => {
105+
mockResolveGoverningPermissionGroupOrganization.mockResolvedValue('org-1')
113106
mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'creator-1' })
114107
const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx
115-
mockTransaction.mockImplementation((callback: (executor: DbOrTx) => Promise<unknown>) =>
116-
callback(tx)
108+
dbChainMockFns.transaction.mockImplementation(
109+
(callback: (executor: DbOrTx) => Promise<unknown>) => callback(tx)
117110
)
118111

119112
await createWorkspace({ ...params, skipDefaultWorkflow: true })
@@ -122,14 +115,40 @@ describe('createWorkspace capability-gate placement', () => {
122115
userId: 'creator-1',
123116
organizationId: 'org-1',
124117
observedOrganizationId: 'org-1',
125-
permissionGroupsGovernCreation: true,
118+
governingPermissionGroupOrganizationId: 'org-1',
119+
})
120+
})
121+
122+
/**
123+
* The preflight policy resolved this value microseconds earlier in the same
124+
* request, and React's `cache()` memo does not span the two calls, so a
125+
* forwarded answer must be used as-is rather than re-read.
126+
*/
127+
it('reuses the governing organization the caller already resolved', async () => {
128+
mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'creator-1' })
129+
const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx
130+
dbChainMockFns.transaction.mockImplementation(
131+
(callback: (executor: DbOrTx) => Promise<unknown>) => callback(tx)
132+
)
133+
134+
await createWorkspace({
135+
...params,
136+
skipDefaultWorkflow: true,
137+
governingPermissionGroupOrganizationId: 'org-1',
126138
})
139+
140+
expect(mockResolveGoverningPermissionGroupOrganization).not.toHaveBeenCalled()
141+
expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith(
142+
tx,
143+
expect.objectContaining({ governingPermissionGroupOrganizationId: 'org-1' })
144+
)
127145
})
128146
})
129147

130148
describe('createDefaultPersonalWorkspaceInTransaction', () => {
131149
beforeEach(() => {
132150
vi.clearAllMocks()
151+
resetDbChainMock()
133152
})
134153

135154
/**
@@ -147,12 +166,27 @@ describe('createDefaultPersonalWorkspaceInTransaction', () => {
147166
userName: 'Ada Lovelace',
148167
})
149168

150-
expect(mockIsWorkspaceCreationGovernedByPermissionGroups).not.toHaveBeenCalled()
169+
expect(mockResolveGoverningPermissionGroupOrganization).not.toHaveBeenCalled()
151170
expect(mockLockWorkspaceCreationContext).toHaveBeenCalledWith(tx, {
152171
userId: 'user-1',
153172
organizationId: null,
154173
observedOrganizationId: null,
155-
permissionGroupsGovernCreation: false,
174+
governingPermissionGroupOrganizationId: null,
156175
})
157176
})
177+
178+
/** The starter workflow is built before the locks, so it must still be written. */
179+
it('seeds the starter workflow it built before taking the locks', async () => {
180+
mockLockWorkspaceCreationContext.mockResolvedValue({ billedAccountUserId: 'user-1' })
181+
const tx = { insert: vi.fn(() => ({ values: vi.fn() })) } as unknown as DbOrTx
182+
183+
await createDefaultPersonalWorkspaceInTransaction(tx, {
184+
userId: 'user-1',
185+
userName: 'Ada Lovelace',
186+
})
187+
188+
expect(
189+
workflowsPersistenceUtilsMockFns.mockSaveWorkflowToNormalizedTables
190+
).toHaveBeenCalledWith(expect.any(String), {}, { workspaceId: null, subjectUserId: null }, tx)
191+
})
158192
})

0 commit comments

Comments
 (0)