From e17ff68118acc5192038ec132cd23575cb675875 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 6 Jul 2026 18:44:24 -0700 Subject: [PATCH 1/2] 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) --- .../sourcebot-public.openapi.json | 83 ++++++---------- .../web/src/app/api/(server)/ee/user/route.ts | 98 +++++++------------ .../src/app/api/(server)/ee/users/route.ts | 33 +------ packages/web/src/app/api/(server)/ee/utils.ts | 14 +++ packages/web/src/openapi/publicApiDocument.ts | 18 ++-- packages/web/src/openapi/publicApiSchemas.ts | 12 +-- 6 files changed, 95 insertions(+), 163 deletions(-) create mode 100644 packages/web/src/app/api/(server)/ee/utils.ts 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(), From 2be8286d427201c338ed87b638aa3c1bb7415419 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 6 Jul 2026 18:45:16 -0700 Subject: [PATCH 2/2] docs: add CHANGELOG entries for EE user API changes Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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)