Skip to content

Commit 2db7e0f

Browse files
committed
fix(sim-links): hold the creator's membership while creating a link
1 parent 69741c4 commit 2db7e0f

3 files changed

Lines changed: 124 additions & 2 deletions

File tree

‎apps/sim/lib/core/application/organization-authorization.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface OrganizationMembershipContext extends OrganizationAuthorization
4747
export interface OrganizationAuthorizationOptions {
4848
executor?: DbOrTx
4949
forUpdate?: boolean
50+
/** Holds the membership row until commit, so a concurrent removal waits for this write. */
51+
forShare?: boolean
5052
}
5153

5254
/** Rechecks routed organization membership; owning a workspace is irrelevant to this grant. */
@@ -83,7 +85,11 @@ export async function requireOrganizationSubjectMembership(
8385
.select({ role: member.role })
8486
.from(member)
8587
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
86-
const [membership] = options.forUpdate ? await query.for('update').limit(1) : await query.limit(1)
88+
const [membership] = options.forUpdate
89+
? await query.for('update').limit(1)
90+
: options.forShare
91+
? await query.for('share').limit(1)
92+
: await query.limit(1)
8793
const parsedRole = organizationRoleSchema.safeParse(membership?.role)
8894
if (!parsedRole.success) throw new OrganizationMembershipNotFoundError()
8995
if (minimumRole === 'admin' && !isOrgAdminRole(parsedRole.data)) {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { db } from '@sim/db'
2+
import { member, organization, simLink, user } from '@sim/db/schema'
3+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
4+
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
5+
import { createDeferred } from '@sim/testing/helpers/deferred'
6+
import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock'
7+
import { generateId } from '@sim/utils/id'
8+
import { eq, inArray } from 'drizzle-orm'
9+
import { afterAll, expect, it, vi } from 'vitest'
10+
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
11+
import { createSimLink } from '@/lib/sim-links/application/use-cases'
12+
13+
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
14+
15+
readTestDatabaseUrl()
16+
17+
const ownerId = generateId()
18+
const creatorId = generateId()
19+
const organizationId = generateId()
20+
const creatorMemberId = generateId()
21+
22+
afterAll(async () => {
23+
await db.delete(organization).where(eq(organization.id, organizationId))
24+
await db.delete(user).where(inArray(user.id, [ownerId, creatorId]))
25+
})
26+
27+
it('refuses a link whose creator is removed after authorization but before the insert', async () => {
28+
await db.insert(user).values(
29+
[ownerId, creatorId].map((id) => ({
30+
id,
31+
name: 'Sim link fixture',
32+
email: `${id}@example.test`,
33+
emailVerified: true,
34+
createdAt: new Date(),
35+
updatedAt: new Date(),
36+
}))
37+
)
38+
await db.insert(organization).values({
39+
id: organizationId,
40+
name: 'Sim link organization',
41+
slug: organizationId,
42+
createdAt: new Date(),
43+
})
44+
await db.insert(member).values([
45+
{ id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() },
46+
{
47+
id: creatorMemberId,
48+
organizationId,
49+
userId: creatorId,
50+
role: 'member',
51+
createdAt: new Date(),
52+
},
53+
])
54+
55+
const authorized = createDeferred<void>()
56+
const proceed = createDeferred<void>()
57+
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => {
58+
if (flag !== 'sim-links') return false
59+
authorized.resolve()
60+
await proceed.promise
61+
return true
62+
})
63+
64+
const creation = createSimLink.execute({
65+
principal: createSessionPrincipal({ userId: creatorId }),
66+
input: {
67+
organizationId,
68+
name: 'handbook',
69+
url: 'https://example.test/handbook',
70+
defaultUrl: null,
71+
description: null,
72+
unlisted: true,
73+
restrictEdit: true,
74+
},
75+
})
76+
const settled = creation.then(
77+
() => null,
78+
(error: unknown) => error
79+
)
80+
await authorized.promise
81+
expect(
82+
(
83+
await removeUserFromOrganization({
84+
organizationId,
85+
userId: creatorId,
86+
memberId: creatorMemberId,
87+
skipBillingLogic: true,
88+
onError: 'throw',
89+
})
90+
).success
91+
).toBe(true)
92+
proceed.resolve()
93+
94+
expect(await settled).toMatchObject({ message: 'Organization not found' })
95+
expect(
96+
await db
97+
.select({ id: simLink.id })
98+
.from(simLink)
99+
.where(eq(simLink.organizationId, organizationId))
100+
).toEqual([])
101+
})

‎apps/sim/lib/sim-links/application/use-cases.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
type OrganizationUseCaseContext,
1010
} from '@/lib/core/application/authorized-organization-use-case'
1111
import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authorized-workspace-use-case'
12-
import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization'
12+
import {
13+
type OrganizationMembershipContext,
14+
requireOrganizationSubjectMembership,
15+
} from '@/lib/core/application/organization-authorization'
1316
import { OrchestrationError } from '@/lib/core/orchestration/types'
1417
import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case'
1518
import type { DbTransaction } from '@/lib/db/types'
@@ -170,6 +173,18 @@ export const createSimLink = defineAuthorizedOrganizationUseCase({
170173
const { organizationId, ...fields } = input
171174
return withUniqueName(organizationId, fields.name, () =>
172175
db.transaction(async (tx) => {
176+
/** A removal deletes this row before handing off the member's links, so it waits for this insert. */
177+
await requireOrganizationSubjectMembership(
178+
context.userId,
179+
organizationId,
180+
'member',
181+
'none',
182+
undefined,
183+
{
184+
executor: tx,
185+
forShare: true,
186+
}
187+
)
173188
const linkId = await repository.insertSimLink(tx, organizationId, context.userId, fields)
174189
return readView(tx, organizationId, linkId, viewerOf(context))
175190
})

0 commit comments

Comments
 (0)