diff --git a/CHANGELOG.md b/CHANGELOG.md
index 457de3370..fdedd55bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)
- Refactored Ask Sourcebot user message text extraction into a shared helper that robustly handles non-text message parts. [#1371](https://github.com/sourcebot-dev/sourcebot/pull/1371)
- Made the backend worker API address configurable via the `WORKER_API_URL` environment variable (default `http://localhost:3060`) instead of being hardcoded. [#1409](https://github.com/sourcebot-dev/sourcebot/pull/1409)
+- [EE] Disabled `DELETE /api/ee/user` while SCIM provisioning is enabled, and switched it to an org-scoped membership removal (with last-owner protection) instead of a global account delete. [#1425](https://github.com/sourcebot-dev/sourcebot/pull/1425)
+- [EE] Unified the `GET /api/ee/user` and `GET /api/ee/users` response shapes behind a shared mapper; the single-user endpoint is now scoped to org membership, and both include role, membership status, and last activity. [#1425](https://github.com/sourcebot-dev/sourcebot/pull/1425)
### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)
diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json
index 3fe693a3e..f56e19241 100644
--- a/docs/api-reference/sourcebot-public.openapi.json
+++ b/docs/api-reference/sourcebot-public.openapi.json
@@ -1088,47 +1088,6 @@
}
},
"PublicEeUser": {
- "type": "object",
- "properties": {
- "name": {
- "type": "string",
- "nullable": true
- },
- "email": {
- "type": "string"
- },
- "createdAt": {
- "type": "string",
- "format": "date-time"
- },
- "updatedAt": {
- "type": "string",
- "format": "date-time"
- }
- },
- "required": [
- "name",
- "email",
- "createdAt",
- "updatedAt"
- ]
- },
- "PublicEeDeleteUserResponse": {
- "type": "object",
- "properties": {
- "success": {
- "type": "boolean"
- },
- "message": {
- "type": "string"
- }
- },
- "required": [
- "success",
- "message"
- ]
- },
- "PublicEeUserListItem": {
"type": "object",
"properties": {
"id": {
@@ -1157,6 +1116,10 @@
"type": "string",
"format": "date-time"
},
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
"lastActivityAt": {
"type": "string",
"nullable": true,
@@ -1170,13 +1133,29 @@
"role",
"suspendedAt",
"createdAt",
+ "updatedAt",
"lastActivityAt"
]
},
+ "PublicEeDeleteUserResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "success",
+ "message"
+ ]
+ },
"PublicEeUsersResponse": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/PublicEeUserListItem"
+ "$ref": "#/components/schemas/PublicEeUser"
}
},
"PublicEeAuditRecord": {
@@ -2291,7 +2270,7 @@
"Enterprise (EE)"
],
"summary": "Get a user",
- "description": "Fetches profile details for a single organization member by `userId`. Only organization owners can access this endpoint.",
+ "description": "Fetches details for a single organization member by `userId`. Only organization owners can access this endpoint.",
"x-mint": {
"content": "\nThis API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription).\n"
},
@@ -2339,7 +2318,7 @@
}
},
"404": {
- "description": "User not found.",
+ "description": "User is not a member of this organization.",
"content": {
"application/json": {
"schema": {
@@ -2365,8 +2344,8 @@
"tags": [
"Enterprise (EE)"
],
- "summary": "Delete a user",
- "description": "Permanently deletes a user and all associated records. Only organization owners can delete other users.",
+ "summary": "Remove a user from the organization",
+ "description": "Removes a user from the organization, revoking their access and their sessions. Only organization owners can access this endpoint.",
"x-mint": {
"content": "\nThis API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription).\n"
},
@@ -2374,17 +2353,17 @@
{
"schema": {
"type": "string",
- "description": "The ID of the user to delete."
+ "description": "The ID of the user to remove."
},
"required": true,
- "description": "The ID of the user to delete.",
+ "description": "The ID of the user to remove.",
"name": "userId",
"in": "query"
}
],
"responses": {
"200": {
- "description": "User deleted successfully.",
+ "description": "User removed successfully.",
"content": {
"application/json": {
"schema": {
@@ -2394,7 +2373,7 @@
}
},
"400": {
- "description": "Missing userId parameter or attempting to delete own account.",
+ "description": "Missing userId parameter.",
"content": {
"application/json": {
"schema": {
@@ -2404,7 +2383,7 @@
}
},
"403": {
- "description": "Insufficient permissions.",
+ "description": "Insufficient permissions, the last active owner cannot be removed, or SCIM provisioning is enabled (membership is managed through your identity provider).",
"content": {
"application/json": {
"schema": {
@@ -2414,7 +2393,7 @@
}
},
"404": {
- "description": "User not found.",
+ "description": "User is not a member of this organization.",
"content": {
"application/json": {
"schema": {
diff --git a/packages/web/src/app/api/(server)/ee/user/route.ts b/packages/web/src/app/api/(server)/ee/user/route.ts
index a91eb17e9..d85f101ef 100644
--- a/packages/web/src/app/api/(server)/ee/user/route.ts
+++ b/packages/web/src/app/api/(server)/ee/user/route.ts
@@ -1,6 +1,10 @@
'use server';
import { createAudit } from "@/ee/features/audit/audit";
+import { membershipManagedByIdpError } from "@/features/membership/errors";
+import { removeMember } from "@/features/membership/membership.service";
+import { isScimEnabled } from "@/features/scim/utils";
+import { toPublicUser } from "../utils";
import { apiHandler } from "@/lib/apiHandler";
import { ErrorCode } from "@/lib/errorCodes";
import { serviceErrorResponse, missingQueryParam, notFound } from "@/lib/serviceError";
@@ -34,19 +38,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
const result = await withAuth(async ({ org, role, user, prisma }) => {
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
try {
- const userData = await prisma.user.findUnique({
+ const membership = await prisma.userToOrg.findUnique({
where: {
- id: userId,
+ orgId_userId: {
+ orgId: org.id,
+ userId,
+ },
},
- select: {
- name: true,
- email: true,
- createdAt: true,
- updatedAt: true,
+ include: {
+ user: true,
},
});
- if (!userData) {
+ if (!membership) {
return notFound('User not found');
}
@@ -63,7 +67,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
orgId: org.id,
});
- return userData;
+ return toPublicUser(membership);
} catch (error) {
logger.error('Error fetching user info', { error, userId });
throw error;
@@ -86,66 +90,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
return serviceErrorResponse(missingQueryParam('userId'));
}
- const result = await withAuth(async ({ org, role, user: currentUser, prisma }) => {
+ const result = await withAuth(async ({ org, role, user: currentUser }) => {
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- try {
- if (currentUser.id === userId) {
- return {
- statusCode: StatusCodes.BAD_REQUEST,
- errorCode: ErrorCode.INVALID_REQUEST_BODY,
- message: 'Cannot delete your own user account',
- };
- }
-
- const targetUser = await prisma.user.findUnique({
- where: {
- id: userId,
- },
- select: {
- id: true,
- email: true,
- name: true,
- },
- });
-
- if (!targetUser) {
- return notFound('User not found');
- }
+ // When SCIM is enabled the IdP is the source of truth for membership,
+ // so deleting users outside of it is disabled.
+ if (await isScimEnabled(org)) {
+ return membershipManagedByIdpError();
+ }
- await createAudit({
- action: "user.delete",
- actor: {
- id: currentUser.id,
- type: "user"
- },
- target: {
- id: userId,
- type: "user"
- },
- orgId: org.id,
- });
+ const error = await removeMember(org.id, userId, {
+ actor: { id: currentUser.id, type: "user" },
+ });
- // Delete the user (cascade will handle all related records)
- await prisma.user.delete({
- where: {
- id: userId,
- },
- });
+ if (isServiceError(error)) {
+ return error;
+ }
- logger.info('User deleted successfully', {
- deletedUserId: userId,
- deletedByUserId: currentUser.id,
- orgId: org.id
- });
+ logger.info('User deleted successfully', {
+ deletedUserId: userId,
+ deletedByUserId: currentUser.id,
+ orgId: org.id,
+ });
- return {
- success: true,
- message: 'User deleted successfully'
- };
- } catch (error) {
- logger.error('Error deleting user', { error, userId });
- throw error;
- }
+ return {
+ success: true,
+ message: 'User deleted successfully',
+ };
});
});
diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts
index ac1cea3de..07ff6f102 100644
--- a/packages/web/src/app/api/(server)/ee/users/route.ts
+++ b/packages/web/src/app/api/(server)/ee/users/route.ts
@@ -1,6 +1,7 @@
'use server';
import { createAudit } from "@/ee/features/audit/audit";
+import { toPublicUser } from "../utils";
import { apiHandler } from "@/lib/apiHandler";
import { serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
@@ -35,33 +36,7 @@ export const GET = apiHandler(async () => {
},
});
- const usersWithActivity = await Promise.all(
- memberships.map(async (membership) => {
- const lastActivity = await prisma.audit.findFirst({
- where: {
- actorId: membership.user.id,
- actorType: 'user',
- orgId: org.id,
- },
- orderBy: {
- timestamp: 'desc',
- },
- select: {
- timestamp: true,
- },
- });
-
- return {
- id: membership.user.id,
- name: membership.user.name,
- email: membership.user.email,
- role: membership.role,
- suspendedAt: membership.suspendedAt,
- createdAt: membership.user.createdAt,
- lastActivityAt: lastActivity?.timestamp ?? null,
- };
- })
- );
+ const users = memberships.map((membership) => toPublicUser(membership));
await createAudit({
action: "user.list",
@@ -76,8 +51,8 @@ export const GET = apiHandler(async () => {
orgId: org.id
});
- logger.info('Fetched users list', { count: usersWithActivity.length });
- return usersWithActivity;
+ logger.info('Fetched users list', { count: users.length });
+ return users;
} catch (error) {
logger.error('Error fetching users', { error });
throw error;
diff --git a/packages/web/src/app/api/(server)/ee/utils.ts b/packages/web/src/app/api/(server)/ee/utils.ts
new file mode 100644
index 000000000..ff7e78f69
--- /dev/null
+++ b/packages/web/src/app/api/(server)/ee/utils.ts
@@ -0,0 +1,14 @@
+import { Prisma } from "@sourcebot/db";
+
+export type MembershipWithUser = Prisma.UserToOrgGetPayload<{ include: { user: true } }>;
+
+export const toPublicUser = (membership: MembershipWithUser) => ({
+ id: membership.user.id,
+ name: membership.user.name,
+ email: membership.user.email,
+ role: membership.role,
+ suspendedAt: membership.suspendedAt,
+ createdAt: membership.user.createdAt,
+ updatedAt: membership.user.updatedAt,
+ lastActivityAt: membership.lastActiveAt,
+});
diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts
index 0a1a1ca2d..d0f11ee84 100644
--- a/packages/web/src/openapi/publicApiDocument.ts
+++ b/packages/web/src/openapi/publicApiDocument.ts
@@ -433,7 +433,7 @@ export function createPublicOpenApiDocument(version: string) {
operationId: 'getUser',
tags: [eeTag.name],
summary: 'Get a user',
- description: 'Fetches profile details for a single organization member by `userId`. Only organization owners can access this endpoint.',
+ description: 'Fetches details for a single organization member by `userId`. Only organization owners can access this endpoint.',
request: {
query: z.object({
userId: z.string().describe('The ID of the user to retrieve.'),
@@ -446,7 +446,7 @@ export function createPublicOpenApiDocument(version: string) {
},
400: errorJson('Missing userId parameter.'),
403: errorJson('Insufficient permissions or entitlement not enabled.'),
- 404: errorJson('User not found.'),
+ 404: errorJson('User is not a member of this organization.'),
500: errorJson('Unexpected failure.'),
},
'x-mint': {
@@ -459,21 +459,21 @@ export function createPublicOpenApiDocument(version: string) {
path: '/api/ee/user',
operationId: 'deleteUser',
tags: [eeTag.name],
- summary: 'Delete a user',
- description: 'Permanently deletes a user and all associated records. Only organization owners can delete other users.',
+ summary: 'Remove a user from the organization',
+ description: 'Removes a user from the organization, revoking their access and their sessions. Only organization owners can access this endpoint.',
request: {
query: z.object({
- userId: z.string().describe('The ID of the user to delete.'),
+ userId: z.string().describe('The ID of the user to remove.'),
}),
},
responses: {
200: {
- description: 'User deleted successfully.',
+ description: 'User removed successfully.',
content: jsonContent(publicEeDeleteUserResponseSchema),
},
- 400: errorJson('Missing userId parameter or attempting to delete own account.'),
- 403: errorJson('Insufficient permissions.'),
- 404: errorJson('User not found.'),
+ 400: errorJson('Missing userId parameter.'),
+ 403: errorJson('Insufficient permissions, the last active owner cannot be removed, or SCIM provisioning is enabled (membership is managed through your identity provider).'),
+ 404: errorJson('User is not a member of this organization.'),
500: errorJson('Unexpected failure.'),
},
'x-mint': {
diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts
index fefdc9071..de1c4011f 100644
--- a/packages/web/src/openapi/publicApiSchemas.ts
+++ b/packages/web/src/openapi/publicApiSchemas.ts
@@ -66,23 +66,17 @@ export const publicHealthResponseSchema = z.object({
// EE: User Management
export const publicEeUserSchema = z.object({
- name: z.string().nullable(),
- email: z.string(),
- createdAt: z.string().datetime(),
- updatedAt: z.string().datetime(),
-}).openapi('PublicEeUser');
-
-export const publicEeUserListItemSchema = z.object({
id: z.string(),
name: z.string().nullable(),
email: z.string(),
role: z.enum(['OWNER', 'MEMBER']),
suspendedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
lastActivityAt: z.string().datetime().nullable(),
-}).openapi('PublicEeUserListItem');
+}).openapi('PublicEeUser');
-export const publicEeUsersResponseSchema = z.array(publicEeUserListItemSchema).openapi('PublicEeUsersResponse');
+export const publicEeUsersResponseSchema = z.array(publicEeUserSchema).openapi('PublicEeUsersResponse');
export const publicEeDeleteUserResponseSchema = z.object({
success: z.boolean(),