Skip to content

Commit e90378a

Browse files
improvement(permissions): confine workspace role changes to existing members (#6180)
* improvement(permissions): confine workspace role changes to existing members * improvement(permissions): retry lock timeouts and audit admin upserts truthfully Bounding the row-lock wait with `lock_timeout` made a competing writer raise 55P03, which the retry set excluded — so the bounded wait failed the request where the unbounded one would have waited. 55P03 is now retried like the other contention aborts, the timeout is shortened since each attempt releases its pooled connection, and contention that outlives every attempt answers with a busy conflict instead of a generic server error. The admin member upsert recorded MEMBER_ADDED even when the conflict branch amended an existing membership, putting a join that never happened in the workspace audit trail. It now records a role change, matching the action the response already reported. Adds the missing test coverage for withTransactionRetry. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9064039 commit e90378a

16 files changed

Lines changed: 1513 additions & 194 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: 63 additions & 21 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,28 +264,53 @@ 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-
})
259-
260-
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
261-
permissions: permissionLevel,
262-
permissionId,
263-
})
264-
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
292+
293+
logger.info(
294+
wasCreated
295+
? `Admin API: Added user ${userId} to workspace ${workspaceId}`
296+
: `Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`,
297+
{ permissions: permissionLevel, permissionId }
298+
)
299+
300+
/**
301+
* The conflict branch amended a membership that already existed, so it is
302+
* a role change rather than an addition — recording it as `MEMBER_ADDED`
303+
* would put a join that never happened in the workspace's audit trail.
304+
*/
265305
recordAudit({
266306
workspaceId,
267307
actorId: 'admin-api',
268-
action: AuditAction.MEMBER_ADDED,
308+
action: wasCreated ? AuditAction.MEMBER_ADDED : AuditAction.MEMBER_ROLE_CHANGED,
269309
resourceType: AuditResourceType.WORKSPACE,
270310
resourceId: workspaceId,
271-
description: `Admin API added member to workspace with ${permissionLevel} permissions`,
311+
description: wasCreated
312+
? `Admin API added member to workspace with ${permissionLevel} permissions`
313+
: `Admin API changed workspace member permissions to ${permissionLevel}`,
272314
metadata: { targetUserId: userId, permissions: permissionLevel },
273315
request,
274316
})
@@ -288,16 +330,16 @@ export const POST = withRouteHandler(
288330
}
289331

290332
return singleResponse({
291-
id: permissionId,
333+
id: written?.id ?? permissionId,
292334
workspaceId,
293335
userId,
294336
permissions: permissionLevel,
295-
createdAt: now.toISOString(),
337+
createdAt: (written?.createdAt ?? now).toISOString(),
296338
updatedAt: now.toISOString(),
297339
userName: userData.name,
298340
userEmail: userData.email,
299341
userImage: userData.image,
300-
action: 'created' as const,
342+
action: wasCreated ? ('created' as const) : ('updated' as const),
301343
})
302344
} catch (error) {
303345
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })

0 commit comments

Comments
 (0)