mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Add generateGroupsListResponse function for SCIM group list responses
This commit is contained in:
@@ -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<JSONObject> = async (
|
||||
team: Team,
|
||||
req: ExpressRequest,
|
||||
projectScimId: string,
|
||||
includeMembers: boolean = true,
|
||||
): Promise<JSONObject> => {
|
||||
let members: JSONObject[] = [];
|
||||
|
||||
if (includeMembers) {
|
||||
const teamMembers: Array<TeamMember> = 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<Team> = {
|
||||
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<Team> = 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<Promise<JSONObject>> = teams.map((team) =>
|
||||
formatTeamForSCIM(team, req, req.params["projectScimId"]!, false), // Don't include members for list to avoid performance issues
|
||||
);
|
||||
|
||||
const groups: Array<JSONObject> = await Promise.all(groupsPromises);
|
||||
|
||||
// Paginate results
|
||||
const paginatedGroups: Array<JSONObject> = 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
771
SCIM_PUSH_GROUPS_README.md
Normal file
771
SCIM_PUSH_GROUPS_README.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user