Skip to content

Commit ab7f6b0

Browse files
committed
improvement(permissions): confine workspace role changes to existing members
1 parent 3740c62 commit ab7f6b0

15 files changed

Lines changed: 1353 additions & 186 deletions

File tree

apps/sim/app/api/organizations/[id]/roster/route.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ describe('GET /api/organizations/[id]/roster', () => {
174174
workspaceId: 'workspace-1',
175175
workspaceName: 'Workspace One',
176176
permission: 'admin',
177+
roleSource: 'org-admin',
178+
isBilledAccount: false,
177179
},
178180
],
179181
}),
@@ -193,6 +195,8 @@ describe('GET /api/organizations/[id]/roster', () => {
193195
workspaceId: 'workspace-1',
194196
workspaceName: 'Workspace One',
195197
permission: 'read',
198+
roleSource: 'explicit',
199+
isBilledAccount: false,
196200
},
197201
],
198202
}),

apps/sim/app/api/organizations/[id]/roster/route.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,17 @@ export const GET = withRouteHandler(
8989
await expireStalePendingInvitationsForOrganization(organizationId)
9090

9191
const orgWorkspaces = await db
92-
.select({ id: workspace.id, name: workspace.name })
92+
.select({
93+
id: workspace.id,
94+
name: workspace.name,
95+
ownerId: workspace.ownerId,
96+
billedAccountUserId: workspace.billedAccountUserId,
97+
})
9398
.from(workspace)
9499
.where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt)))
95100

96101
const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id)
97-
const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name]))
102+
const workspaceById = new Map(orgWorkspaces.map((ws) => [ws.id, ws]))
98103
const memberUserIds = memberRows.map((row) => row.userId)
99104

100105
const memberPermissions =
@@ -117,11 +122,14 @@ export const GET = withRouteHandler(
117122

118123
const permissionsByUser = new Map<string, RosterWorkspaceAccess[]>()
119124
for (const row of memberPermissions) {
125+
const ws = workspaceById.get(row.workspaceId)
120126
const list = permissionsByUser.get(row.userId) ?? []
121127
list.push({
122128
workspaceId: row.workspaceId,
123-
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
129+
workspaceName: ws?.name ?? 'Workspace',
124130
permission: row.permission,
131+
roleSource: ws?.ownerId === row.userId ? 'owner' : 'explicit',
132+
isBilledAccount: ws?.billedAccountUserId === row.userId,
125133
})
126134
permissionsByUser.set(row.userId, list)
127135
}
@@ -135,6 +143,14 @@ export const GET = withRouteHandler(
135143
workspaceId: ws.id,
136144
workspaceName: ws.name,
137145
permission: 'admin' as const,
146+
/**
147+
* Owner wins over the derived organization grant, matching
148+
* `getUsersWithPermissions` — otherwise the same person reads as
149+
* `owner` in the teammates list and `org-admin` here.
150+
*/
151+
roleSource:
152+
ws.ownerId === rosterMember.userId ? ('owner' as const) : ('org-admin' as const),
153+
isBilledAccount: ws.billedAccountUserId === rosterMember.userId,
138154
}))
139155
: (permissionsByUser.get(rosterMember.userId) ?? []),
140156
}
@@ -183,10 +199,13 @@ export const GET = withRouteHandler(
183199

184200
for (const row of externalPermissionRows) {
185201
const existing = externalMembersByUser.get(row.userId)
202+
const externalWorkspace = workspaceById.get(row.workspaceId)
186203
const workspaceAccess: RosterWorkspaceAccess = {
187204
workspaceId: row.workspaceId,
188-
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
205+
workspaceName: externalWorkspace?.name ?? 'Workspace',
189206
permission: row.permission,
207+
roleSource: externalWorkspace?.ownerId === row.userId ? 'owner' : 'explicit',
208+
isBilledAccount: externalWorkspace?.billedAccountUserId === row.userId,
190209
}
191210

192211
if (existing) {
@@ -247,8 +266,11 @@ export const GET = withRouteHandler(
247266
const list = grantsByInvitation.get(row.invitationId) ?? []
248267
list.push({
249268
workspaceId: row.workspaceId,
250-
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
269+
workspaceName: workspaceById.get(row.workspaceId)?.name ?? 'Workspace',
251270
permission: row.permission,
271+
/** A pending invitee holds no row yet, so nothing is inherited. */
272+
roleSource: 'explicit',
273+
isBilledAccount: false,
252274
})
253275
grantsByInvitation.set(row.invitationId, list)
254276
}
@@ -269,7 +291,7 @@ export const GET = withRouteHandler(
269291
const data = {
270292
members: rosterMembers,
271293
pendingInvitations,
272-
workspaces: orgWorkspaces,
294+
workspaces: orgWorkspaces.map((ws) => ({ id: ws.id, name: ws.name })),
273295
} satisfies OrganizationRoster
274296
return NextResponse.json({
275297
success: true,

apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
4545
import {
4646
badRequestResponse,
47+
conflictResponse,
4748
internalErrorResponse,
4849
notFoundResponse,
4950
singleResponse,
@@ -170,10 +171,20 @@ export const PATCH = withRouteHandler(
170171

171172
const now = new Date()
172173

173-
await db
174+
/**
175+
* Conditional on the row read above still existing: a concurrent removal
176+
* between that read and this write would otherwise match nothing and be
177+
* reported to the caller as a successful update.
178+
*/
179+
const updated = await db
174180
.update(permissions)
175181
.set({ permissionType: permissionLevel, updatedAt: now })
176182
.where(eq(permissions.id, memberId))
183+
.returning({ id: permissions.id })
184+
185+
if (updated.length === 0) {
186+
return conflictResponse('Workspace member changed during the update. Retry.')
187+
}
177188

178189
const [userData] = await db
179190
.select({ name: user.name, email: user.email, image: user.image })

apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
*
1010
* Response: AdminListResponse<AdminWorkspaceMember>
1111
*
12+
* `createdAt` is the member's join time. It previously moved on every role
13+
* change, because the in-app role-change endpoint replaced the permission row
14+
* rather than amending it; that endpoint now updates in place, so only
15+
* `updatedAt` tracks role changes. Consumers that diffed `createdAt` to detect
16+
* recently-changed members must read `updatedAt` instead.
17+
*
1218
* POST /api/v1/admin/workspaces/[id]/members
1319
*
1420
* Add a user to a workspace with a specific permission level.
@@ -55,6 +61,7 @@ import {
5561
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
5662
import {
5763
badRequestResponse,
64+
conflictResponse,
5865
internalErrorResponse,
5966
listResponse,
6067
notFoundResponse,
@@ -191,10 +198,20 @@ export const POST = withRouteHandler(
191198
if (existingPermission) {
192199
if (existingPermission.permissionType !== permissionLevel) {
193200
const now = new Date()
194-
await db
201+
/**
202+
* Conditional on the row read above still existing: a concurrent
203+
* removal between that read and this write would otherwise match
204+
* nothing and be reported to the caller as a successful update.
205+
*/
206+
const updated = await db
195207
.update(permissions)
196208
.set({ permissionType: permissionLevel, updatedAt: now })
197209
.where(eq(permissions.id, existingPermission.id))
210+
.returning({ id: permissions.id })
211+
212+
if (updated.length === 0) {
213+
return conflictResponse('Workspace member changed during the update. Retry.')
214+
}
198215

199216
logger.info(`Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`, {
200217
previousPermissions: existingPermission.permissionType,
@@ -247,15 +264,31 @@ export const POST = withRouteHandler(
247264
const now = new Date()
248265
const permissionId = generateId()
249266

250-
await db.insert(permissions).values({
251-
id: permissionId,
252-
userId,
253-
entityType: 'workspace',
254-
entityId: workspaceId,
255-
permissionType: permissionLevel,
256-
createdAt: now,
257-
updatedAt: now,
258-
})
267+
/**
268+
* The existence read above is unlocked, so two concurrent adds for the
269+
* same user both reach here. Conflicting on the uniqueness constraint
270+
* settles it as the requested role instead of failing the loser with a
271+
* 500 for a request that did what it asked.
272+
*/
273+
const [written] = await db
274+
.insert(permissions)
275+
.values({
276+
id: permissionId,
277+
userId,
278+
entityType: 'workspace',
279+
entityId: workspaceId,
280+
permissionType: permissionLevel,
281+
createdAt: now,
282+
updatedAt: now,
283+
})
284+
.onConflictDoUpdate({
285+
target: [permissions.userId, permissions.entityType, permissions.entityId],
286+
set: { permissionType: permissionLevel, updatedAt: now },
287+
})
288+
.returning({ id: permissions.id, createdAt: permissions.createdAt })
289+
290+
/** A returned id we did not mint means the conflict branch ran. */
291+
const wasCreated = written?.id === permissionId
259292

260293
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
261294
permissions: permissionLevel,
@@ -288,16 +321,16 @@ export const POST = withRouteHandler(
288321
}
289322

290323
return singleResponse({
291-
id: permissionId,
324+
id: written?.id ?? permissionId,
292325
workspaceId,
293326
userId,
294327
permissions: permissionLevel,
295-
createdAt: now.toISOString(),
328+
createdAt: (written?.createdAt ?? now).toISOString(),
296329
updatedAt: now.toISOString(),
297330
userName: userData.name,
298331
userEmail: userData.email,
299332
userImage: userData.image,
300-
action: 'created' as const,
333+
action: wasCreated ? ('created' as const) : ('updated' as const),
301334
})
302335
} catch (error) {
303336
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })

0 commit comments

Comments
 (0)