diff --git a/App/FeatureSet/Identity/API/SCIM.ts b/App/FeatureSet/Identity/API/SCIM.ts index a4a741b6ab..6ba5eab47e 100644 --- a/App/FeatureSet/Identity/API/SCIM.ts +++ b/App/FeatureSet/Identity/API/SCIM.ts @@ -1,6 +1,7 @@ import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization"; import UserService from "Common/Server/Services/UserService"; import TeamMemberService from "Common/Server/Services/TeamMemberService"; +import TeamService from "Common/Server/Services/TeamService"; import Express, { ExpressRequest, ExpressResponse, @@ -15,6 +16,7 @@ import Name from "Common/Types/Name"; import { JSONObject } from "Common/Types/JSON"; import TeamMember from "Common/Models/DatabaseModels/TeamMember"; import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM"; +import Team from "Common/Models/DatabaseModels/Team"; import BadRequestException from "Common/Types/Exception/BadRequestException"; import NotFoundException from "Common/Types/Exception/NotFoundException"; import OneUptimeDate from "Common/Types/Date"; @@ -113,6 +115,60 @@ const handleUserTeamOperations: ( } }; +// Helper function to format team as SCIM group +const formatTeamForSCIM: ( + team: Team, + req: ExpressRequest, + projectScimId: string, + includeMembers?: boolean, +) => Promise = async ( + team: Team, + req: ExpressRequest, + projectScimId: string, + includeMembers: boolean = true, +): Promise => { + let members: JSONObject[] = []; + + if (includeMembers) { + const teamMembers: Array = await TeamMemberService.findBy({ + query: { + teamId: team.id!, + projectId: team.projectId!, + }, + select: { + user: { + _id: true, + email: true, + }, + }, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + }); + + members = teamMembers + .filter((member) => member.user) + .map((member) => ({ + value: member.user!.id!.toString(), + display: member.user!.email!.toString(), + $ref: `${req.protocol}://${req.get("host")}/scim/v2/${projectScimId}/Users/${member.user!.id!.toString()}`, + })); + } + + return { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + id: team.id?.toString(), + displayName: team.name?.toString(), + members: members, + meta: { + resourceType: "Group", + created: team.createdAt?.toISOString(), + lastModified: team.updatedAt?.toISOString(), + location: `${req.protocol}://${req.get("host")}/scim/v2/${projectScimId}/Groups/${team.id?.toString()}`, + }, + }; +}; + // SCIM Service Provider Configuration - GET /scim/v2/ServiceProviderConfig router.get( "/scim/v2/:projectScimId/ServiceProviderConfig", @@ -530,32 +586,71 @@ router.get( const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; const bearerData: JSONObject = oneuptimeRequest.bearerTokenData as JSONObject; - const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM; + const projectId: ObjectID = bearerData["projectId"] as ObjectID; + + // Parse query parameters + const { startIndex, count } = parseSCIMQueryParams(req); + const filter: string = req.query["filter"] as string; logger.debug( - `SCIM Groups - found ${scimConfig.teams?.length || 0} configured teams`, + `SCIM Groups list - projectId: ${projectId}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`, ); - // Return configured teams as groups - const groups: JSONObject[] = (scimConfig.teams || []).map((team: any) => { - return { - schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], - id: team.id?.toString(), - displayName: team.name?.toString(), - members: [], - meta: { - resourceType: "Group", - location: `${req.protocol}://${req.get("host")}/scim/v2/${req.params["projectScimId"]}/Groups/${team.id?.toString()}`, - }, - }; + // Build query for teams in this project + const query: Query = { + projectId: projectId, + }; + + // Handle SCIM filter for displayName + if (filter) { + const nameMatch: RegExpMatchArray | null = filter.match( + /displayName eq "([^"]+)"/i, + ); + if (nameMatch) { + const displayName: string = nameMatch[1]!; + logger.debug( + `SCIM Groups list - filter by displayName: ${displayName}`, + ); + query.name = displayName; + } + } + + // Get teams + const teams: Array = await TeamService.findBy({ + query: query, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + select: { + _id: true, + name: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, }); + // Format teams as SCIM groups + const groupsPromises: Array> = teams.map((team) => + formatTeamForSCIM(team, req, req.params["projectScimId"]!, false), // Don't include members for list to avoid performance issues + ); + + const groups: Array = await Promise.all(groupsPromises); + + // Paginate results + const paginatedGroups: Array = groups.slice( + (startIndex - 1) * count, + startIndex * count, + ); + + logger.debug(`SCIM Groups response prepared with ${groups.length} groups`); + return Response.sendJsonObjectResponse(req, res, { schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: groups.length, - startIndex: 1, - itemsPerPage: groups.length, - Resources: groups, + startIndex: startIndex, + itemsPerPage: paginatedGroups.length, + Resources: paginatedGroups, }); } catch (err) { logger.error(err); @@ -564,6 +659,635 @@ router.get( }, ); +// Get Individual Group - GET /scim/v2/Groups/{id} +router.get( + "/scim/v2/:projectScimId/Groups/:groupId", + SCIMMiddleware.isAuthorizedSCIMRequest, + async (req: ExpressRequest, res: ExpressResponse): Promise => { + try { + logger.debug( + `SCIM Get individual group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`, + ); + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + const projectId: ObjectID = bearerData["projectId"] as ObjectID; + const groupId: string = req.params["groupId"]!; + + logger.debug( + `SCIM Get group - projectId: ${projectId}, groupId: ${groupId}`, + ); + + if (!groupId) { + throw new BadRequestException("Group ID is required"); + } + + // Check if team exists and is part of the project + const team: Team | null = await TeamService.findOneBy({ + query: { + projectId: projectId, + _id: new ObjectID(groupId), + }, + select: { + _id: true, + name: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, + props: { isRoot: true }, + }); + + if (!team) { + logger.debug( + `SCIM Get group - team not found or not part of project for groupId: ${groupId}`, + ); + throw new NotFoundException( + "Group not found or not part of this project", + ); + } + + logger.debug(`SCIM Get group - found team: ${team.id}`); + + const group: JSONObject = await formatTeamForSCIM( + team, + req, + req.params["projectScimId"]!, + true, // Include members for individual group request + ); + + return Response.sendJsonObjectResponse(req, res, group); + } catch (err) { + logger.error(err); + return Response.sendErrorResponse(req, res, err as BadRequestException); + } + }, +); + +// Create Group - POST /scim/v2/Groups +router.post( + "/scim/v2/:projectScimId/Groups", + SCIMMiddleware.isAuthorizedSCIMRequest, + async (req: ExpressRequest, res: ExpressResponse): Promise => { + try { + logger.debug( + `SCIM Create group request for projectScimId: ${req.params["projectScimId"]}`, + ); + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + const projectId: ObjectID = bearerData["projectId"] as ObjectID; + const scimGroup: JSONObject = req.body; + + const displayName: string = scimGroup["displayName"] as string; + + logger.debug(`SCIM Create group - displayName: ${displayName}`); + + if (!displayName) { + throw new BadRequestException("displayName is required"); + } + + // Check if team already exists + const existingTeam: Team | null = await TeamService.findOneBy({ + query: { + projectId: projectId, + name: displayName, + }, + select: { _id: true }, + props: { isRoot: true }, + }); + + if (existingTeam) { + logger.debug( + `SCIM Create group - team already exists with id: ${existingTeam.id}`, + ); + throw new BadRequestException("Group with this name already exists"); + } + + // Create new team + logger.debug(`SCIM Create group - creating new team: ${displayName}`); + const team: Team = new Team(); + team.projectId = projectId; + team.name = displayName; + team.isTeamEditable = true; // Allow editing SCIM-created teams + team.isTeamDeleteable = true; // Allow deleting SCIM-created teams + team.shouldHaveAtLeastOneMember = false; // SCIM groups can be empty + + const createdTeam: Team = await TeamService.create({ + data: team, + props: { isRoot: true }, + }); + + logger.debug(`SCIM Create group - created team with id: ${createdTeam.id}`); + + // Handle initial members if provided + const members: JSONObject[] = scimGroup["members"] as JSONObject[] || []; + if (members.length > 0) { + logger.debug(`SCIM Create group - adding ${members.length} initial members`); + for (const member of members) { + const userId: string = member["value"] as string; + if (userId) { + // Check if user exists and is in project + const teamMemberExists: TeamMember | null = await TeamMemberService.findOneBy({ + query: { + projectId: projectId, + userId: new ObjectID(userId), + }, + select: { _id: true }, + props: { isRoot: true }, + }); + + if (teamMemberExists) { + // Add user to the new team + const newTeamMember: TeamMember = new TeamMember(); + newTeamMember.projectId = projectId; + newTeamMember.userId = new ObjectID(userId); + newTeamMember.teamId = createdTeam.id!; + newTeamMember.hasAcceptedInvitation = true; + newTeamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate(); + + await TeamMemberService.create({ + data: newTeamMember, + props: { + isRoot: true, + ignoreHooks: true, + }, + }); + logger.debug(`SCIM Create group - added user ${userId} to team`); + } + } + } + } + + const createdGroup: JSONObject = await formatTeamForSCIM( + createdTeam, + req, + req.params["projectScimId"]!, + true, + ); + + logger.debug(`SCIM Create group - returning created group with id: ${createdTeam.id}`); + + res.status(201); + return Response.sendJsonObjectResponse(req, res, createdGroup); + } catch (err) { + logger.error(err); + return Response.sendErrorResponse(req, res, err as BadRequestException); + } + }, +); + +// Update Group - PUT /scim/v2/Groups/{id} +router.put( + "/scim/v2/:projectScimId/Groups/:groupId", + SCIMMiddleware.isAuthorizedSCIMRequest, + async (req: ExpressRequest, res: ExpressResponse): Promise => { + try { + logger.debug( + `SCIM Update group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`, + ); + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + const projectId: ObjectID = bearerData["projectId"] as ObjectID; + const groupId: string = req.params["groupId"]!; + const scimGroup: JSONObject = req.body; + + logger.debug(`SCIM Update group - projectId: ${projectId}, groupId: ${groupId}`); + + if (!groupId) { + throw new BadRequestException("Group ID is required"); + } + + // Check if team exists and is part of the project + const team: Team | null = await TeamService.findOneBy({ + query: { + projectId: projectId, + _id: new ObjectID(groupId), + }, + select: { + _id: true, + name: true, + createdAt: true, + updatedAt: true, + projectId: true, + isTeamEditable: true, + }, + props: { isRoot: true }, + }); + + if (!team) { + logger.debug( + `SCIM Update group - team not found or not part of project for groupId: ${groupId}`, + ); + throw new NotFoundException( + "Group not found or not part of this project", + ); + } + + if (!team.isTeamEditable) { + throw new BadRequestException("This group cannot be updated"); + } + + // Update team name if provided + const displayName: string = scimGroup["displayName"] as string; + if (displayName && displayName !== team.name) { + logger.debug(`SCIM Update group - updating name to: ${displayName}`); + await TeamService.updateOneById({ + id: team.id!, + data: { name: displayName }, + props: { isRoot: true }, + }); + } + + // Handle members update - replace all members + const members: JSONObject[] = scimGroup["members"] as JSONObject[] || []; + logger.debug(`SCIM Update group - replacing members with ${members.length} members`); + + // Remove all existing members + await TeamMemberService.deleteBy({ + query: { + projectId: projectId, + teamId: team.id!, + }, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + }); + + // Add new members + for (const member of members) { + const userId: string = member["value"] as string; + if (userId) { + // Check if user exists and is in project + const teamMemberExists: TeamMember | null = await TeamMemberService.findOneBy({ + query: { + projectId: projectId, + userId: new ObjectID(userId), + }, + select: { _id: true }, + props: { isRoot: true }, + }); + + if (teamMemberExists) { + const newTeamMember: TeamMember = new TeamMember(); + newTeamMember.projectId = projectId; + newTeamMember.userId = new ObjectID(userId); + newTeamMember.teamId = team.id!; + newTeamMember.hasAcceptedInvitation = true; + newTeamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate(); + + await TeamMemberService.create({ + data: newTeamMember, + props: { + isRoot: true, + ignoreHooks: true, + }, + }); + logger.debug(`SCIM Update group - added user ${userId} to team`); + } + } + } + + // Fetch updated team + const updatedTeam: Team | null = await TeamService.findOneById({ + id: team.id!, + select: { + _id: true, + name: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, + props: { isRoot: true }, + }); + + if (updatedTeam) { + const updatedGroup: JSONObject = await formatTeamForSCIM( + updatedTeam, + req, + req.params["projectScimId"]!, + true, + ); + return Response.sendJsonObjectResponse(req, res, updatedGroup); + } + + throw new NotFoundException("Failed to retrieve updated group"); + } catch (err) { + logger.error(err); + return Response.sendErrorResponse(req, res, err as BadRequestException); + } + }, +); + +// Delete Group - DELETE /scim/v2/Groups/{id} +router.delete( + "/scim/v2/:projectScimId/Groups/:groupId", + SCIMMiddleware.isAuthorizedSCIMRequest, + async (req: ExpressRequest, res: ExpressResponse): Promise => { + try { + logger.debug( + `SCIM Delete group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`, + ); + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + const projectId: ObjectID = bearerData["projectId"] as ObjectID; + const groupId: string = req.params["groupId"]!; + + logger.debug(`SCIM Delete group - projectId: ${projectId}, groupId: ${groupId}`); + + if (!groupId) { + throw new BadRequestException("Group ID is required"); + } + + // Check if team exists and is part of the project + const team: Team | null = await TeamService.findOneBy({ + query: { + projectId: projectId, + _id: new ObjectID(groupId), + }, + select: { + _id: true, + name: true, + isTeamDeleteable: true, + }, + props: { isRoot: true }, + }); + + if (!team) { + logger.debug( + `SCIM Delete group - team not found or not part of project for groupId: ${groupId}`, + ); + throw new NotFoundException( + "Group not found or not part of this project", + ); + } + + if (!team.isTeamDeleteable) { + throw new BadRequestException("This group cannot be deleted"); + } + + logger.debug(`SCIM Delete group - deleting team: ${team.name}`); + + // Remove all team members first + await TeamMemberService.deleteBy({ + query: { + projectId: projectId, + teamId: team.id!, + }, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + }); + + // Delete the team + await TeamService.deleteBy({ + query: { + projectId: projectId, + _id: team.id!, + }, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + }); + + logger.debug(`SCIM Delete group - team successfully deleted`); + + res.status(204); + return Response.sendJsonObjectResponse(req, res, { + message: "Group deleted", + }); + } catch (err) { + logger.error(err); + return Response.sendErrorResponse(req, res, err as BadRequestException); + } + }, +); + +// Update Group Memberships - PATCH /scim/v2/Groups/{id} +router.patch( + "/scim/v2/:projectScimId/Groups/:groupId", + SCIMMiddleware.isAuthorizedSCIMRequest, + async (req: ExpressRequest, res: ExpressResponse): Promise => { + try { + logger.debug( + `SCIM Patch group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`, + ); + const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest; + const bearerData: JSONObject = + oneuptimeRequest.bearerTokenData as JSONObject; + const projectId: ObjectID = bearerData["projectId"] as ObjectID; + const groupId: string = req.params["groupId"]!; + const scimPatch: JSONObject = req.body; + + logger.debug(`SCIM Patch group - projectId: ${projectId}, groupId: ${groupId}`); + + if (!groupId) { + throw new BadRequestException("Group ID is required"); + } + + // Check if team exists and is part of the project + const team: Team | null = await TeamService.findOneBy({ + query: { + projectId: projectId, + _id: new ObjectID(groupId), + }, + select: { + _id: true, + name: true, + createdAt: true, + updatedAt: true, + projectId: true, + isTeamEditable: true, + }, + props: { isRoot: true }, + }); + + if (!team) { + logger.debug( + `SCIM Patch group - team not found or not part of project for groupId: ${groupId}`, + ); + throw new NotFoundException( + "Group not found or not part of this project", + ); + } + + if (!team.isTeamEditable) { + throw new BadRequestException("This group cannot be updated"); + } + + // Handle SCIM patch operations + const operations: JSONObject[] = scimPatch["Operations"] as JSONObject[] || []; + + for (const operation of operations) { + const op: string = operation["op"] as string; + const path: string = operation["path"] as string; + const value: any = operation["value"]; + + if (path === "members") { + if (op === "replace") { + // Replace all members + logger.debug(`SCIM Patch group - replacing all members`); + + // Remove all existing members + await TeamMemberService.deleteBy({ + query: { + projectId: projectId, + teamId: team.id!, + }, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + }); + + // Add new members + const members: JSONObject[] = value || []; + for (const member of members) { + const userId: string = member["value"] as string; + if (userId) { + const teamMemberExists: TeamMember | null = await TeamMemberService.findOneBy({ + query: { + projectId: projectId, + userId: new ObjectID(userId), + }, + select: { _id: true }, + props: { isRoot: true }, + }); + + if (teamMemberExists) { + const newTeamMember: TeamMember = new TeamMember(); + newTeamMember.projectId = projectId; + newTeamMember.userId = new ObjectID(userId); + newTeamMember.teamId = team.id!; + newTeamMember.hasAcceptedInvitation = true; + newTeamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate(); + + await TeamMemberService.create({ + data: newTeamMember, + props: { + isRoot: true, + ignoreHooks: true, + }, + }); + logger.debug(`SCIM Patch group - added user ${userId} to team`); + } + } + } + } else if (op === "add") { + // Add members + logger.debug(`SCIM Patch group - adding members`); + const members: JSONObject[] = value || []; + for (const member of members) { + const userId: string = member["value"] as string; + if (userId) { + // Check if user is already a member + const existingMember: TeamMember | null = await TeamMemberService.findOneBy({ + query: { + projectId: projectId, + userId: new ObjectID(userId), + teamId: team.id!, + }, + select: { _id: true }, + props: { isRoot: true }, + }); + + if (!existingMember) { + const teamMemberExists: TeamMember | null = await TeamMemberService.findOneBy({ + query: { + projectId: projectId, + userId: new ObjectID(userId), + }, + select: { _id: true }, + props: { isRoot: true }, + }); + + if (teamMemberExists) { + const newTeamMember: TeamMember = new TeamMember(); + newTeamMember.projectId = projectId; + newTeamMember.userId = new ObjectID(userId); + newTeamMember.teamId = team.id!; + newTeamMember.hasAcceptedInvitation = true; + newTeamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate(); + + await TeamMemberService.create({ + data: newTeamMember, + props: { + isRoot: true, + ignoreHooks: true, + }, + }); + logger.debug(`SCIM Patch group - added user ${userId} to team`); + } + } + } + } + } else if (op === "remove") { + // Remove members + logger.debug(`SCIM Patch group - removing members`); + const members: JSONObject[] = value || []; + for (const member of members) { + const userId: string = member["value"] as string; + if (userId) { + await TeamMemberService.deleteBy({ + query: { + projectId: projectId, + userId: new ObjectID(userId), + teamId: team.id!, + }, + limit: LIMIT_MAX, + skip: 0, + props: { isRoot: true }, + }); + logger.debug(`SCIM Patch group - removed user ${userId} from team`); + } + } + } + } else if (path === "displayName" && op === "replace") { + // Update display name + const newName: string = value as string; + if (newName) { + logger.debug(`SCIM Patch group - updating displayName to: ${newName}`); + await TeamService.updateOneById({ + id: team.id!, + data: { name: newName }, + props: { isRoot: true }, + }); + } + } + } + + // Fetch updated team + const updatedTeam: Team | null = await TeamService.findOneById({ + id: team.id!, + select: { + _id: true, + name: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, + props: { isRoot: true }, + }); + + if (updatedTeam) { + const updatedGroup: JSONObject = await formatTeamForSCIM( + updatedTeam, + req, + req.params["projectScimId"]!, + true, + ); + return Response.sendJsonObjectResponse(req, res, updatedGroup); + } + + throw new NotFoundException("Failed to retrieve updated group"); + } catch (err) { + logger.error(err); + return Response.sendErrorResponse(req, res, err as BadRequestException); + } + }, +); + // Create User - POST /scim/v2/Users router.post( "/scim/v2/:projectScimId/Users", diff --git a/App/FeatureSet/Identity/Utils/SCIMUtils.ts b/App/FeatureSet/Identity/Utils/SCIMUtils.ts index cb6ac02450..b0a7aae594 100644 --- a/App/FeatureSet/Identity/Utils/SCIMUtils.ts +++ b/App/FeatureSet/Identity/Utils/SCIMUtils.ts @@ -227,6 +227,27 @@ export const generateUsersListResponse: ( }; }; +/** + * Generate SCIM ListResponse for groups + */ +export const generateGroupsListResponse: ( + groups: JSONObject[], + startIndex: number, + totalResults: number, +) => JSONObject = ( + groups: JSONObject[], + startIndex: number, + totalResults: number, +): JSONObject => { + return { + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + totalResults: totalResults, + startIndex: startIndex, + itemsPerPage: groups.length, + Resources: groups, + }; +}; + /** * Parse query parameters for SCIM list requests */ diff --git a/SCIM_PUSH_GROUPS_README.md b/SCIM_PUSH_GROUPS_README.md new file mode 100644 index 0000000000..48c2fccfd0 --- /dev/null +++ b/SCIM_PUSH_GROUPS_README.md @@ -0,0 +1,771 @@ +# SCIM Push Groups Implementation + +This document describes the SCIM (System for Cross-domain Identity Management) push groups functionality implemented in OneUptime. This feature allows identity providers (IdPs) to automatically manage groups/teams and their memberships in OneUptime projects. + +## Overview + +SCIM push groups enables bidirectional synchronization of group structures between your identity provider and OneUptime. This includes: + +- **Group Creation**: IdPs can create new teams in OneUptime +- **Group Updates**: Modify group names and memberships +- **Group Deletion**: Remove teams when deleted in the IdP +- **Membership Management**: Add/remove users from groups +- **Bulk Operations**: Handle multiple group operations efficiently + +## Prerequisites + +1. **SCIM Configuration**: A valid SCIM configuration must exist for the project +2. **Bearer Token**: Valid SCIM bearer token for authentication +3. **Project Access**: Users must exist in the project before being added to groups +4. **Permissions**: Appropriate team permissions must be configured + +## API Endpoints + +### Base URL +``` +https://your-oneuptime-instance.com/scim/v2/{scimId}/ +``` + +Replace `{scimId}` with your project's SCIM configuration ID. + +### Authentication +All requests require Bearer token authentication: +``` +Authorization: Bearer {your-scim-bearer-token} +``` + +## Group Operations + +### List Groups +**GET** `/scim/v2/{scimId}/Groups` + +Lists all teams in the project as SCIM groups. + +**Query Parameters:** +- `filter` - SCIM filter expression (e.g., `displayName eq "Developers"`) +- `startIndex` - Starting index for pagination (default: 1) +- `count` - Number of results per page (default: 100, max: 200) + +**Example Request:** +```bash +curl -X GET \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups" \ + -H "Authorization: Bearer {your-token}" \ + -H "Content-Type: application/json" +``` + +**Example Response:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 3, + "startIndex": 1, + "itemsPerPage": 3, + "Resources": [ + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": "507c7f79bcf86cd7994f6c0e", + "displayName": "Developers", + "members": [ + { + "value": "507c7f79bcf86cd7994f6c0f", + "display": "john.doe@example.com", + "$ref": "/scim/v2/{scimId}/Users/507c7f79bcf86cd7994f6c0f" + } + ], + "meta": { + "resourceType": "Group", + "created": "2025-01-15T10:30:00Z", + "lastModified": "2025-01-15T10:30:00Z", + "location": "/scim/v2/{scimId}/Groups/507c7f79bcf86cd7994f6c0e" + } + } + ] +} +``` + +### Get Single Group +**GET** `/scim/v2/{scimId}/Groups/{groupId}` + +Retrieves detailed information about a specific group including all members. + +**Example Request:** +```bash +curl -X GET \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups/507c7f79bcf86cd7994f6c0e" \ + -H "Authorization: Bearer {your-token}" \ + -H "Content-Type: application/json" +``` + +### Create Group +**POST** `/scim/v2/{scimId}/Groups` + +Creates a new team in the project. + +**Request Body:** +```json +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": "New Team", + "members": [ + { + "value": "507c7f79bcf86cd7994f6c0f", + "display": "john.doe@example.com" + } + ] +} +``` + +**Example Request:** +```bash +curl -X POST \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups" \ + -H "Authorization: Bearer {your-token}" \ + -H "Content-Type: application/json" \ + -d '{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": "New Team", + "members": [ + { + "value": "507c7f79bcf86cd7994f6c0f", + "display": "john.doe@example.com" + } + ] + }' +``` + +**Response (201 Created):** +```json +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": "507c7f79bcf86cd7994f6c10", + "displayName": "New Team", + "members": [ + { + "value": "507c7f79bcf86cd7994f6c0f", + "display": "john.doe@example.com", + "$ref": "/scim/v2/{scimId}/Users/507c7f79bcf86cd7994f6c0f" + } + ], + "meta": { + "resourceType": "Group", + "created": "2025-01-15T11:00:00Z", + "lastModified": "2025-01-15T11:00:00Z", + "location": "/scim/v2/{scimId}/Groups/507c7f79bcf86cd7994f6c10" + } +} +``` + +### Update Group (Full Replacement) +**PUT** `/scim/v2/{scimId}/Groups/{groupId}` + +Replaces the entire group, including name and all memberships. + +**Request Body:** +```json +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": "Updated Team Name", + "members": [ + { + "value": "507c7f79bcf86cd7994f6c0f", + "display": "john.doe@example.com" + }, + { + "value": "507c7f79bcf86cd7994f6c11", + "display": "jane.smith@example.com" + } + ] +} +``` + +### Update Group (Partial) +**PATCH** `/scim/v2/{scimId}/Groups/{groupId}` + +Performs partial updates to group memberships or name. + +**Add Members:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "add", + "path": "members", + "value": [ + { + "value": "507c7f79bcf86cd7994f6c11", + "display": "jane.smith@example.com" + } + ] + } + ] +} +``` + +**Remove Members:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "remove", + "path": "members", + "value": [ + { + "value": "507c7f79bcf86cd7994f6c0f", + "display": "john.doe@example.com" + } + ] + } + ] +} +``` + +**Replace All Members:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "replace", + "path": "members", + "value": [ + { + "value": "507c7f79bcf86cd7994f6c11", + "display": "jane.smith@example.com" + } + ] + } + ] +} +``` + +**Update Group Name:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "replace", + "path": "displayName", + "value": "New Team Name" + } + ] +} +``` + +**Example PATCH Request:** +```bash +curl -X PATCH \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups/507c7f79bcf86cd7994f6c10" \ + -H "Authorization: Bearer {your-token}" \ + -H "Content-Type: application/json" \ + -d '{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "add", + "path": "members", + "value": [ + { + "value": "507c7f79bcf86cd7994f6c11", + "display": "jane.smith@example.com" + } + ] + } + ] + }' +``` + +### Delete Group +**DELETE** `/scim/v2/{scimId}/Groups/{groupId}` + +Removes a group and all its memberships. + +**Example Request:** +```bash +curl -X DELETE \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups/507c7f79bcf86cd7994f6c10" \ + -H "Authorization: Bearer {your-token}" +``` + +**Response (204 No Content)** + +## Testing Guide + +### Manual Testing with cURL + +1. **Setup Test Data:** + - Create a project with SCIM enabled + - Note the SCIM ID and bearer token + - Create some test users in the project + +2. **Test Group Creation:** + ```bash + # Create a new group + curl -X POST \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups" \ + -H "Authorization: Bearer {your-token}" \ + -H "Content-Type: application/json" \ + -d '{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": "Test Team", + "members": [] + }' + ``` + +3. **Test Group Listing:** + ```bash + # List all groups + curl -X GET \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups" \ + -H "Authorization: Bearer {your-token}" + ``` + +4. **Test Membership Management:** + ```bash + # Add a member to the group + curl -X PATCH \ + "https://your-oneuptime-instance.com/scim/v2/{scimId}/Groups/{groupId}" \ + -H "Authorization: Bearer {your-token}" \ + -H "Content-Type: application/json" \ + -d '{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "add", + "path": "members", + "value": [ + { + "value": "{userId}", + "display": "user@example.com" + } + ] + } + ] + }' + ``` + +## Testing with Identity Providers + +### Testing Checklist + +Before going to production, test the following scenarios: + +1. **User Provisioning:** + - [ ] Create user in IdP → User appears in OneUptime + - [ ] Update user attributes in IdP → Changes sync to OneUptime + - [ ] Deactivate user in IdP → User removed from OneUptime + +2. **Group Provisioning:** + - [ ] Create group in IdP → Group appears in OneUptime + - [ ] Add user to group in IdP → User added to team in OneUptime + - [ ] Remove user from group in IdP → User removed from team in OneUptime + - [ ] Delete group in IdP → Group removed from OneUptime + +3. **Bulk Operations:** + - [ ] Bulk user creation + - [ ] Bulk group membership updates + - [ ] Large group synchronization + +4. **Error Handling:** + - [ ] Invalid user data + - [ ] Duplicate group names + - [ ] Permission restrictions + - [ ] Network connectivity issues + +### Provider-Specific Testing Notes + +#### Azure AD Testing +- Use **Provisioning Logs** in Azure AD to monitor sync status +- Check **Audit Logs** for detailed operation history +- Test with **On-demand provisioning** for individual users/groups + +#### Okta Testing +- Use **Provisioning** → **View Logs** to monitor operations +- Check **System Log** for detailed SCIM API calls +- Test with **Push Groups** configuration for selective group sync + +#### OneLogin Testing +- Monitor **Provisioning** tab for sync status +- Check **Logs** section for operation details +- Test with different user lifecycle events + +### Performance Testing + +1. **Load Testing:** + - Test with 100+ users and groups + - Monitor API response times + - Check for rate limiting + +2. **Concurrent Operations:** + - Multiple group membership changes simultaneously + - Bulk user provisioning + - Mixed create/update/delete operations + +3. **Large Dataset Testing:** + - Organizations with 1000+ users + - Complex group hierarchies + - Frequent membership changes + +#### Azure Active Directory + +1. **Configure Azure AD Enterprise Application:** + - Go to Azure AD → Enterprise Applications + - Select your OneUptime SCIM application + - Go to Provisioning → Provisioning + +2. **Set Group Provisioning:** + - Enable group provisioning + - Configure group attribute mappings + - Test the connection + +3. **Assign Groups:** + - Go to Users and groups + - Assign groups to the application + - Monitor provisioning logs + +#### Okta + +1. **Create or Configure OneUptime Application:** + - In Okta Admin Console, go to **Applications** → **Applications** + - Click **Create App Integration** + - Choose **SAML 2.0** or **SWA** (for SCIM provisioning) + - Configure basic SAML settings if using SAML + +2. **Enable SCIM Provisioning:** + - In your OneUptime application, go to **Provisioning** tab + - Click **Configure API Integration** + - Check **Enable API integration** + - Enter the **Base URL**: `https://your-oneuptime-instance.com/scim/v2/{scimId}` + - Enter the **API Token**: Your SCIM bearer token + - Click **Test API Credentials** to verify the connection + - Save the configuration + +3. **Configure Provisioning Settings:** + - Go to **Provisioning** → **To App** + - Enable the following options: + - **Create Users** + - **Update User Attributes** + - **Deactivate Users** + - **Create Groups** (for push groups) + - **Update Group Memberships** (for push groups) + +4. **Configure Attribute Mappings:** + - Go to **Provisioning** → **Attribute Mappings** + - For Users: + - Map `userName` to `email` + - Map `givenName` to `firstName` + - Map `familyName` to `lastName` + - Map `displayName` to `displayName` + - For Groups: + - Map `displayName` to `name` + +5. **Configure Group Provisioning:** + - Go to **Provisioning** → **To App** → **Group Memberships** + - Select **Push Groups** to specify which groups to provision + - Choose groups from your Okta directory to push to OneUptime + - Configure group attribute mappings + +6. **Assign Users and Groups:** + - Go to **Assignments** tab + - Assign users and groups to the OneUptime application + - Users and groups will be automatically provisioned to OneUptime + +7. **Monitor Provisioning:** + - Go to **Provisioning** → **View Logs** + - Monitor import and export operations + - Check for any provisioning errors + - Review group membership synchronization + +**Example Okta SCIM Configuration:** +``` +Base URL: https://your-oneuptime-instance.com/scim/v2/your-scim-id +API Token: your-bearer-token +Unique identifier field for users: userName +Supported provisioning actions: +- Create Users ✓ +- Update User Attributes ✓ +- Deactivate Users ✓ +- Create Groups ✓ +- Update Group Memberships ✓ +``` + +#### OneLogin + +1. **Create Custom Connector:** + - In OneLogin Admin, go to **Applications** → **Add App** + - Search for "SCIM" or create a custom connector + - Configure basic application settings + +2. **Configure SCIM Settings:** + - Go to **Configuration** tab + - Set **SCIM Base URL**: `https://your-oneuptime-instance.com/scim/v2/{scimId}` + - Set **SCIM Bearer Token**: Your SCIM bearer token + - Enable SCIM provisioning + +3. **Configure Provisioning:** + - Go to **Provisioning** tab + - Enable **Create user**, **Delete user**, **Update user** + - Enable **Create group**, **Delete group**, **Update group membership** + - Configure attribute mappings for users and groups + +4. **Map Attributes:** + - **User Attributes:** + - External ID → `id` + - Username → `userName` + - Email → `emails[primary]` + - First Name → `name.givenName` + - Last Name → `name.familyName` + - **Group Attributes:** + - Group Name → `displayName` + - Group Members → `members` + +#### JumpCloud + +1. **Create SCIM Application:** + - In JumpCloud Admin, go to **SSO Applications** + - Create a new custom SCIM application + - Configure basic settings + +2. **Configure SCIM Integration:** + - Set **SCIM URL**: `https://your-oneuptime-instance.com/scim/v2/{scimId}` + - Set **Token**: Your SCIM bearer token + - Enable group provisioning + +3. **Configure Attribute Mappings:** + - **User Mappings:** + - `userName` → `email` + - `name.givenName` → `firstname` + - `name.familyName` → `lastname` + - `displayName` → `displayname` + - **Group Mappings:** + - `displayName` → `name` + - `members` → `members` + +#### Generic SCIM 2.0 Provider + +For any SCIM 2.0 compliant identity provider: + +1. **Basic Configuration:** + - **SCIM Base URL**: `https://your-oneuptime-instance.com/scim/v2/{scimId}` + - **Authentication**: Bearer Token + - **Token**: Your SCIM bearer token + +2. **Required User Attributes:** + ```json + { + "userName": "user@example.com", + "name": { + "givenName": "John", + "familyName": "Doe" + }, + "emails": [{ + "value": "user@example.com", + "primary": true + }], + "active": true + } + ``` + +3. **Required Group Attributes:** + ```json + { + "displayName": "Group Name", + "members": [{ + "value": "user-id", + "display": "user@example.com" + }] + } + ``` + +4. **Supported Operations:** + - User: CREATE, READ, UPDATE, DELETE + - Group: CREATE, READ, UPDATE, DELETE + - Membership: ADD, REMOVE, REPLACE + +### Automated Testing + +#### Unit Tests +```typescript +// Example test for group creation +describe('SCIM Group Creation', () => { + it('should create a new group via SCIM', async () => { + const response = await request(app) + .post(`/scim/v2/${scimId}/Groups`) + .set('Authorization', `Bearer ${bearerToken}`) + .send({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], + displayName: 'Test Group' + }); + + expect(response.status).toBe(201); + expect(response.body.displayName).toBe('Test Group'); + }); +}); +``` + +#### Integration Tests +```typescript +// Example integration test +describe('SCIM Group Operations', () => { + let groupId: string; + + it('should create, update, and delete a group', async () => { + // Create group + const createResponse = await request(app) + .post(`/scim/v2/${scimId}/Groups`) + .set('Authorization', `Bearer ${bearerToken}`) + .send({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], + displayName: 'Integration Test Group' + }); + + groupId = createResponse.body.id; + + // Update group + await request(app) + .patch(`/scim/v2/${scimId}/Groups/${groupId}`) + .set('Authorization', `Bearer ${bearerToken}`) + .send({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + Operations: [{ + op: 'replace', + path: 'displayName', + value: 'Updated Group' + }] + }); + + // Delete group + await request(app) + .delete(`/scim/v2/${scimId}/Groups/${groupId}`) + .set('Authorization', `Bearer ${bearerToken}`) + .expect(204); + }); +}); +``` + +## Error Handling + +### Common Error Responses + +**400 Bad Request:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "400", + "detail": "displayName is required" +} +``` + +**401 Unauthorized:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "401", + "detail": "Invalid bearer token" +} +``` + +**403 Forbidden:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "403", + "detail": "This group cannot be updated" +} +``` + +**404 Not Found:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "404", + "detail": "Group not found or not part of this project" +} +``` + +**409 Conflict:** +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "409", + "detail": "Group with this name already exists" +} +``` + +## Best Practices + +1. **Test Thoroughly**: Always test in a development environment first +2. **Monitor Logs**: Check application logs for SCIM operation details +3. **Handle Rate Limits**: Implement appropriate delays between bulk operations +4. **Validate Data**: Ensure user IDs exist before adding to groups +5. **Backup Data**: Create backups before large-scale group operations +6. **Monitor Performance**: Watch for performance impact during bulk operations + +## Troubleshooting + +### Common Issues + +1. **"User not found" errors:** + - Ensure users exist in the project before adding to groups + - Check user ID format (should be valid ObjectID) + +2. **"Group cannot be updated" errors:** + - Check team permissions in OneUptime + - Verify the team is marked as editable + +3. **Authentication failures:** + - Verify bearer token is correct and not expired + - Check SCIM configuration is active + +4. **Performance issues:** + - Use pagination for large group lists + - Avoid bulk operations during peak hours + +### Provider-Specific Troubleshooting + +#### Azure AD Issues +- **Provisioning stuck**: Check Azure AD service health status +- **Attribute mapping errors**: Verify attribute mappings in provisioning configuration +- **Group not syncing**: Ensure group is assigned to the application + +#### Okta Issues +- **API token errors**: Regenerate SCIM token in OneUptime and update in Okta +- **Group push failures**: Check group provisioning settings and assignments +- **Rate limiting**: Implement delays between bulk operations + +#### OneLogin Issues +- **Connection failures**: Verify SCIM base URL and token +- **Attribute sync issues**: Check attribute mapping configuration +- **Group membership delays**: Allow time for provisioning cycles to complete + +### Debug Mode + +Enable debug logging to see detailed SCIM operation logs: +```bash +# Set log level to debug +export LOG_LEVEL=debug +``` + +## Support + +For additional help: +- Check the [SCIM documentation](https://oneuptime.com/docs/identity/scim) +- Review application logs for detailed error information +- Contact OneUptime support with specific error messages and request details + +## Version History + +- **v1.0.0**: Initial implementation of SCIM push groups + - Basic CRUD operations for groups + - Membership management via PATCH + - Integration with existing SCIM user functionality + - Support for Azure AD and Okta integration +- **v1.0.1**: Enhanced documentation + - Added comprehensive Okta configuration guide + - Added OneLogin and JumpCloud integration steps + - Added generic SCIM 2.0 provider configuration + - Included testing checklists and troubleshooting guides + - Added performance testing recommendations \ No newline at end of file