Commit 7b4e737
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
- workspaces
- lib
- permission-groups
- workspaces
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | | - | |
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
17 | | - | |
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
18 | 19 | | |
19 | 20 | | |
20 | | - | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
24 | 25 | | |
25 | 26 | | |
26 | | - | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| |||
Lines changed: 0 additions & 30 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | 44 | | |
75 | 45 | | |
76 | 46 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
157 | 158 | | |
158 | 159 | | |
159 | 160 | | |
| 161 | + | |
160 | 162 | | |
161 | 163 | | |
162 | 164 | | |
| |||
207 | 209 | | |
208 | 210 | | |
209 | 211 | | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
210 | 226 | | |
211 | 227 | | |
212 | 228 | | |
| |||
220 | 236 | | |
221 | 237 | | |
222 | 238 | | |
| 239 | + | |
223 | 240 | | |
224 | 241 | | |
225 | 242 | | |
| |||
231 | 248 | | |
232 | 249 | | |
233 | 250 | | |
| 251 | + | |
234 | 252 | | |
235 | 253 | | |
236 | 254 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
1 | 2 | | |
2 | 3 | | |
3 | 4 | | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
8 | | - | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
9 | 13 | | |
10 | 14 | | |
11 | 15 | | |
| |||
90 | 94 | | |
91 | 95 | | |
92 | 96 | | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
| 25 | + | |
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
| |||
90 | 91 | | |
91 | 92 | | |
92 | 93 | | |
93 | | - | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
94 | 102 | | |
95 | | - | |
| 103 | + | |
| 104 | + | |
96 | 105 | | |
97 | | - | |
| 106 | + | |
98 | 107 | | |
99 | 108 | | |
100 | 109 | | |
| |||
181 | 190 | | |
182 | 191 | | |
183 | 192 | | |
184 | | - | |
| 193 | + | |
185 | 194 | | |
186 | 195 | | |
187 | 196 | | |
| |||
281 | 290 | | |
282 | 291 | | |
283 | 292 | | |
284 | | - | |
| 293 | + | |
285 | 294 | | |
286 | 295 | | |
| 296 | + | |
| 297 | + | |
287 | 298 | | |
288 | | - | |
289 | | - | |
290 | | - | |
291 | | - | |
292 | | - | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
293 | 323 | | |
294 | | - | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
295 | 338 | | |
296 | 339 | | |
0 commit comments