Skip to content

Commit e17ff68

Browse files
feat(web): guard EE user deletion behind SCIM and unify user API shape
DELETE /api/ee/user now returns membershipManagedByIdpError when SCIM is enabled (the IdP owns membership), matching the existing member-management actions. It also delegates to removeMember, so it does an org-scoped membership removal with last-owner protection and credential revocation instead of a raw global user delete. The self-delete guard is removed; removeMember's last-owner protection covers the risky case. GET /api/ee/user and GET /api/ee/users now return an identical user shape via a shared toPublicUser mapper. The singular GET is now scoped to org membership (404 for non-members) instead of a global user lookup, and lastActivityAt is sourced from UserToOrg.lastActiveAt (dropping a per-member audit query / N+1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11ef472 commit e17ff68

6 files changed

Lines changed: 95 additions & 163 deletions

File tree

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 31 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,47 +1088,6 @@
10881088
}
10891089
},
10901090
"PublicEeUser": {
1091-
"type": "object",
1092-
"properties": {
1093-
"name": {
1094-
"type": "string",
1095-
"nullable": true
1096-
},
1097-
"email": {
1098-
"type": "string"
1099-
},
1100-
"createdAt": {
1101-
"type": "string",
1102-
"format": "date-time"
1103-
},
1104-
"updatedAt": {
1105-
"type": "string",
1106-
"format": "date-time"
1107-
}
1108-
},
1109-
"required": [
1110-
"name",
1111-
"email",
1112-
"createdAt",
1113-
"updatedAt"
1114-
]
1115-
},
1116-
"PublicEeDeleteUserResponse": {
1117-
"type": "object",
1118-
"properties": {
1119-
"success": {
1120-
"type": "boolean"
1121-
},
1122-
"message": {
1123-
"type": "string"
1124-
}
1125-
},
1126-
"required": [
1127-
"success",
1128-
"message"
1129-
]
1130-
},
1131-
"PublicEeUserListItem": {
11321091
"type": "object",
11331092
"properties": {
11341093
"id": {
@@ -1157,6 +1116,10 @@
11571116
"type": "string",
11581117
"format": "date-time"
11591118
},
1119+
"updatedAt": {
1120+
"type": "string",
1121+
"format": "date-time"
1122+
},
11601123
"lastActivityAt": {
11611124
"type": "string",
11621125
"nullable": true,
@@ -1170,13 +1133,29 @@
11701133
"role",
11711134
"suspendedAt",
11721135
"createdAt",
1136+
"updatedAt",
11731137
"lastActivityAt"
11741138
]
11751139
},
1140+
"PublicEeDeleteUserResponse": {
1141+
"type": "object",
1142+
"properties": {
1143+
"success": {
1144+
"type": "boolean"
1145+
},
1146+
"message": {
1147+
"type": "string"
1148+
}
1149+
},
1150+
"required": [
1151+
"success",
1152+
"message"
1153+
]
1154+
},
11761155
"PublicEeUsersResponse": {
11771156
"type": "array",
11781157
"items": {
1179-
"$ref": "#/components/schemas/PublicEeUserListItem"
1158+
"$ref": "#/components/schemas/PublicEeUser"
11801159
}
11811160
},
11821161
"PublicEeAuditRecord": {
@@ -2291,7 +2270,7 @@
22912270
"Enterprise (EE)"
22922271
],
22932272
"summary": "Get a user",
2294-
"description": "Fetches profile details for a single organization member by `userId`. Only organization owners can access this endpoint.",
2273+
"description": "Fetches details for a single organization member by `userId`. Only organization owners can access this endpoint.",
22952274
"x-mint": {
22962275
"content": "<Note>\nThis API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription).\n</Note>"
22972276
},
@@ -2339,7 +2318,7 @@
23392318
}
23402319
},
23412320
"404": {
2342-
"description": "User not found.",
2321+
"description": "User is not a member of this organization.",
23432322
"content": {
23442323
"application/json": {
23452324
"schema": {
@@ -2365,26 +2344,26 @@
23652344
"tags": [
23662345
"Enterprise (EE)"
23672346
],
2368-
"summary": "Delete a user",
2369-
"description": "Permanently deletes a user and all associated records. Only organization owners can delete other users.",
2347+
"summary": "Remove a user from the organization",
2348+
"description": "Removes a user from the organization, revoking their access and their sessions. Only organization owners can access this endpoint.",
23702349
"x-mint": {
23712350
"content": "<Note>\nThis API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription).\n</Note>"
23722351
},
23732352
"parameters": [
23742353
{
23752354
"schema": {
23762355
"type": "string",
2377-
"description": "The ID of the user to delete."
2356+
"description": "The ID of the user to remove."
23782357
},
23792358
"required": true,
2380-
"description": "The ID of the user to delete.",
2359+
"description": "The ID of the user to remove.",
23812360
"name": "userId",
23822361
"in": "query"
23832362
}
23842363
],
23852364
"responses": {
23862365
"200": {
2387-
"description": "User deleted successfully.",
2366+
"description": "User removed successfully.",
23882367
"content": {
23892368
"application/json": {
23902369
"schema": {
@@ -2394,7 +2373,7 @@
23942373
}
23952374
},
23962375
"400": {
2397-
"description": "Missing userId parameter or attempting to delete own account.",
2376+
"description": "Missing userId parameter.",
23982377
"content": {
23992378
"application/json": {
24002379
"schema": {
@@ -2404,7 +2383,7 @@
24042383
}
24052384
},
24062385
"403": {
2407-
"description": "Insufficient permissions.",
2386+
"description": "Insufficient permissions, the last active owner cannot be removed, or SCIM provisioning is enabled (membership is managed through your identity provider).",
24082387
"content": {
24092388
"application/json": {
24102389
"schema": {
@@ -2414,7 +2393,7 @@
24142393
}
24152394
},
24162395
"404": {
2417-
"description": "User not found.",
2396+
"description": "User is not a member of this organization.",
24182397
"content": {
24192398
"application/json": {
24202399
"schema": {

packages/web/src/app/api/(server)/ee/user/route.ts

Lines changed: 34 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
'use server';
22

33
import { createAudit } from "@/ee/features/audit/audit";
4+
import { membershipManagedByIdpError } from "@/features/membership/errors";
5+
import { removeMember } from "@/features/membership/membership.service";
6+
import { isScimEnabled } from "@/features/scim/utils";
7+
import { toPublicUser } from "../utils";
48
import { apiHandler } from "@/lib/apiHandler";
59
import { ErrorCode } from "@/lib/errorCodes";
610
import { serviceErrorResponse, missingQueryParam, notFound } from "@/lib/serviceError";
@@ -34,19 +38,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
3438
const result = await withAuth(async ({ org, role, user, prisma }) => {
3539
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
3640
try {
37-
const userData = await prisma.user.findUnique({
41+
const membership = await prisma.userToOrg.findUnique({
3842
where: {
39-
id: userId,
43+
orgId_userId: {
44+
orgId: org.id,
45+
userId,
46+
},
4047
},
41-
select: {
42-
name: true,
43-
email: true,
44-
createdAt: true,
45-
updatedAt: true,
48+
include: {
49+
user: true,
4650
},
4751
});
4852

49-
if (!userData) {
53+
if (!membership) {
5054
return notFound('User not found');
5155
}
5256

@@ -63,7 +67,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
6367
orgId: org.id,
6468
});
6569

66-
return userData;
70+
return toPublicUser(membership);
6771
} catch (error) {
6872
logger.error('Error fetching user info', { error, userId });
6973
throw error;
@@ -86,66 +90,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
8690
return serviceErrorResponse(missingQueryParam('userId'));
8791
}
8892

89-
const result = await withAuth(async ({ org, role, user: currentUser, prisma }) => {
93+
const result = await withAuth(async ({ org, role, user: currentUser }) => {
9094
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
91-
try {
92-
if (currentUser.id === userId) {
93-
return {
94-
statusCode: StatusCodes.BAD_REQUEST,
95-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
96-
message: 'Cannot delete your own user account',
97-
};
98-
}
99-
100-
const targetUser = await prisma.user.findUnique({
101-
where: {
102-
id: userId,
103-
},
104-
select: {
105-
id: true,
106-
email: true,
107-
name: true,
108-
},
109-
});
110-
111-
if (!targetUser) {
112-
return notFound('User not found');
113-
}
95+
// When SCIM is enabled the IdP is the source of truth for membership,
96+
// so deleting users outside of it is disabled.
97+
if (await isScimEnabled(org)) {
98+
return membershipManagedByIdpError();
99+
}
114100

115-
await createAudit({
116-
action: "user.delete",
117-
actor: {
118-
id: currentUser.id,
119-
type: "user"
120-
},
121-
target: {
122-
id: userId,
123-
type: "user"
124-
},
125-
orgId: org.id,
126-
});
101+
const error = await removeMember(org.id, userId, {
102+
actor: { id: currentUser.id, type: "user" },
103+
});
127104

128-
// Delete the user (cascade will handle all related records)
129-
await prisma.user.delete({
130-
where: {
131-
id: userId,
132-
},
133-
});
105+
if (isServiceError(error)) {
106+
return error;
107+
}
134108

135-
logger.info('User deleted successfully', {
136-
deletedUserId: userId,
137-
deletedByUserId: currentUser.id,
138-
orgId: org.id
139-
});
109+
logger.info('User deleted successfully', {
110+
deletedUserId: userId,
111+
deletedByUserId: currentUser.id,
112+
orgId: org.id,
113+
});
140114

141-
return {
142-
success: true,
143-
message: 'User deleted successfully'
144-
};
145-
} catch (error) {
146-
logger.error('Error deleting user', { error, userId });
147-
throw error;
148-
}
115+
return {
116+
success: true,
117+
message: 'User deleted successfully',
118+
};
149119
});
150120
});
151121

packages/web/src/app/api/(server)/ee/users/route.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use server';
22

33
import { createAudit } from "@/ee/features/audit/audit";
4+
import { toPublicUser } from "../utils";
45
import { apiHandler } from "@/lib/apiHandler";
56
import { serviceErrorResponse } from "@/lib/serviceError";
67
import { isServiceError } from "@/lib/utils";
@@ -35,33 +36,7 @@ export const GET = apiHandler(async () => {
3536
},
3637
});
3738

38-
const usersWithActivity = await Promise.all(
39-
memberships.map(async (membership) => {
40-
const lastActivity = await prisma.audit.findFirst({
41-
where: {
42-
actorId: membership.user.id,
43-
actorType: 'user',
44-
orgId: org.id,
45-
},
46-
orderBy: {
47-
timestamp: 'desc',
48-
},
49-
select: {
50-
timestamp: true,
51-
},
52-
});
53-
54-
return {
55-
id: membership.user.id,
56-
name: membership.user.name,
57-
email: membership.user.email,
58-
role: membership.role,
59-
suspendedAt: membership.suspendedAt,
60-
createdAt: membership.user.createdAt,
61-
lastActivityAt: lastActivity?.timestamp ?? null,
62-
};
63-
})
64-
);
39+
const users = memberships.map((membership) => toPublicUser(membership));
6540

6641
await createAudit({
6742
action: "user.list",
@@ -76,8 +51,8 @@ export const GET = apiHandler(async () => {
7651
orgId: org.id
7752
});
7853

79-
logger.info('Fetched users list', { count: usersWithActivity.length });
80-
return usersWithActivity;
54+
logger.info('Fetched users list', { count: users.length });
55+
return users;
8156
} catch (error) {
8257
logger.error('Error fetching users', { error });
8358
throw error;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Prisma } from "@sourcebot/db";
2+
3+
export type MembershipWithUser = Prisma.UserToOrgGetPayload<{ include: { user: true } }>;
4+
5+
export const toPublicUser = (membership: MembershipWithUser) => ({
6+
id: membership.user.id,
7+
name: membership.user.name,
8+
email: membership.user.email,
9+
role: membership.role,
10+
suspendedAt: membership.suspendedAt,
11+
createdAt: membership.user.createdAt,
12+
updatedAt: membership.user.updatedAt,
13+
lastActivityAt: membership.lastActiveAt,
14+
});

0 commit comments

Comments
 (0)