mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f3bfcebb | ||
|
|
0690417a54 | ||
|
|
bda70a24dc | ||
|
|
32cdd4fe65 | ||
|
|
4579db4c59 | ||
|
|
010de82ccb | ||
|
|
08d42c7923 | ||
|
|
4f76afb9f2 |
@@ -13,6 +13,7 @@ import TelemetryAPI from "Common/Server/API/TelemetryAPI";
|
||||
import ProbeAPI from "Common/Server/API/ProbeAPI";
|
||||
import ProjectAPI from "Common/Server/API/ProjectAPI";
|
||||
import ProjectSsoAPI from "Common/Server/API/ProjectSSO";
|
||||
import ProjectScimAPI from "Common/Server/API/ProjectSCIM";
|
||||
|
||||
// Import API
|
||||
import ResellerPlanAPI from "Common/Server/API/ResellerPlanAPI";
|
||||
@@ -1573,6 +1574,10 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ProjectSsoAPI().getRouter(),
|
||||
);
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ProjectScimAPI().getRouter(),
|
||||
);
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ResellerPlanAPI().getRouter(),
|
||||
|
||||
512
App/FeatureSet/Identity/API/ProjectSCIM.ts
Normal file
512
App/FeatureSet/Identity/API/ProjectSCIM.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import SCIMUtil from "../Utils/SCIM";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import Exception from "Common/Types/Exception/Exception";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ProjectScimService from "Common/Server/Services/ProjectScimService";
|
||||
import UserService from "Common/Server/Services/UserService";
|
||||
import TeamMemberService from "Common/Server/Services/TeamMemberService";
|
||||
import Select from "Common/Server/Types/Database/Select";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import ProjectScim from "Common/Models/DatabaseModels/ProjectScim";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import Email from "Common/Types/Email";
|
||||
import Name from "Common/Types/Name";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// Test SCIM connection
|
||||
router.post(
|
||||
"/test-connection",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectId: ObjectID = req.params["projectId"]
|
||||
? new ObjectID(req.params["projectId"])
|
||||
: req.body["projectId"];
|
||||
|
||||
if (!projectId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Project ID is required"),
|
||||
);
|
||||
}
|
||||
|
||||
const scimBaseUrl: string = req.body["scimBaseUrl"];
|
||||
const bearerToken: string = req.body["bearerToken"];
|
||||
|
||||
if (!scimBaseUrl || !bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM Base URL and Bearer Token are required"),
|
||||
);
|
||||
}
|
||||
|
||||
const isConnected: boolean = await SCIMUtil.testConnection(
|
||||
URL.fromString(scimBaseUrl),
|
||||
bearerToken,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
isConnected,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sync users from SCIM provider
|
||||
router.post(
|
||||
"/:projectScimId/sync-users",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"]!);
|
||||
|
||||
const projectScim: ProjectScim | null = await ProjectScimService.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
teams: {
|
||||
_id: true,
|
||||
name: true,
|
||||
} as Select<Team>,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get users from SCIM provider
|
||||
const scimUsers = await SCIMUtil.listUsers(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
);
|
||||
|
||||
const syncResults = {
|
||||
totalScimUsers: scimUsers.totalResults,
|
||||
processedUsers: 0,
|
||||
createdUsers: 0,
|
||||
updatedUsers: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Process each SCIM user
|
||||
for (const scimUser of scimUsers.Resources) {
|
||||
try {
|
||||
syncResults.processedUsers++;
|
||||
|
||||
if (!scimUser.emails || scimUser.emails.length === 0) {
|
||||
syncResults.errors.push(`User ${scimUser.userName} has no email address`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const primaryEmail = scimUser.emails.find(e => e.primary) || scimUser.emails[0];
|
||||
if (!primaryEmail) {
|
||||
syncResults.errors.push(`User ${scimUser.userName} has no valid email address`);
|
||||
continue;
|
||||
}
|
||||
const email = new Email(primaryEmail.value);
|
||||
|
||||
// Check if user exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email },
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user && projectScim.autoProvisionUsers) {
|
||||
// Update existing user - only update if displayName exists
|
||||
if (scimUser.displayName) {
|
||||
await UserService.updateOneById({
|
||||
id: user.id!,
|
||||
data: {
|
||||
name: new Name(scimUser.displayName),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
syncResults.updatedUsers++;
|
||||
} else if (!user && projectScim.autoProvisionUsers) {
|
||||
// Create new user
|
||||
const newUser = new User();
|
||||
newUser.name = new Name(scimUser.displayName || scimUser.userName);
|
||||
newUser.email = email;
|
||||
newUser.isEmailVerified = true;
|
||||
|
||||
const createdUser = await UserService.create({
|
||||
data: newUser,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (createdUser) {
|
||||
syncResults.createdUsers++;
|
||||
|
||||
// Add user to teams if specified
|
||||
if (projectScim.teams && projectScim.teams.length > 0) {
|
||||
for (const team of projectScim.teams) {
|
||||
try {
|
||||
// Create a new TeamMember instance
|
||||
const newTeamMember = new TeamMember();
|
||||
newTeamMember.teamId = team.id!;
|
||||
newTeamMember.userId = createdUser.id!;
|
||||
newTeamMember.projectId = projectScim.projectId!;
|
||||
newTeamMember.hasAcceptedInvitation = true;
|
||||
|
||||
await TeamMemberService.create({
|
||||
data: newTeamMember,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (teamError) {
|
||||
logger.error(`Error adding user to team: ${teamError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (userError) {
|
||||
syncResults.errors.push(`Error processing user ${scimUser.userName}: ${userError}`);
|
||||
logger.error(`Error processing SCIM user: ${userError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, syncResults);
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Deprovision users from SCIM provider
|
||||
router.post(
|
||||
"/:projectScimId/deprovision-users",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"]!);
|
||||
|
||||
const projectScim: ProjectScim | null = await ProjectScimService.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoDeprovisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto-deprovisioning is not enabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get all users from the project by finding team members
|
||||
const teamMembers = await TeamMemberService.findBy({
|
||||
query: {
|
||||
projectId: projectScim.projectId!,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
} as Select<User>,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Extract unique users from team members
|
||||
const uniqueUserIds = new Set<string>();
|
||||
const projectUsers: User[] = teamMembers
|
||||
.map(tm => tm.user!)
|
||||
.filter(user => {
|
||||
if (user && user.id && !uniqueUserIds.has(user.id.toString())) {
|
||||
uniqueUserIds.add(user.id.toString());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const deprovisionResults = {
|
||||
totalProjectUsers: projectUsers.length,
|
||||
processedUsers: 0,
|
||||
deprovisionedUsers: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Process each project user
|
||||
for (const user of projectUsers) {
|
||||
try {
|
||||
deprovisionResults.processedUsers++;
|
||||
|
||||
if (!user.email) {
|
||||
deprovisionResults.errors.push(`User ${user.id} has no email address`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user exists in SCIM provider
|
||||
const scimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email.toString(),
|
||||
);
|
||||
|
||||
if (!scimUser) {
|
||||
// User not found in SCIM provider, deprovision from project
|
||||
try {
|
||||
// Remove user from all teams in this project
|
||||
const teamMembers = await TeamMemberService.findBy({
|
||||
query: {
|
||||
userId: user.id!,
|
||||
projectId: projectScim.projectId!,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
await TeamMemberService.deleteOneById({
|
||||
id: teamMember.id!,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deprovisionResults.deprovisionedUsers++;
|
||||
logger.info(`Deprovisioned user ${user.email} from project due to SCIM removal`);
|
||||
} catch (deprovisionError) {
|
||||
deprovisionResults.errors.push(`Error deprovisioning user ${user.email}: ${deprovisionError}`);
|
||||
logger.error(`Error deprovisioning user: ${deprovisionError}`);
|
||||
}
|
||||
}
|
||||
} catch (userError) {
|
||||
deprovisionResults.errors.push(`Error processing user ${user.email}: ${userError}`);
|
||||
logger.error(`Error processing user for deprovisioning: ${userError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, deprovisionResults);
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Deprovision specific user
|
||||
router.post(
|
||||
"/:projectScimId/deprovision-user",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"]!);
|
||||
|
||||
const projectScim: ProjectScim | null = await ProjectScimService.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoDeprovisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto-deprovisioning is not enabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
const userId: ObjectID = new ObjectID(req.body["userId"]);
|
||||
|
||||
// Get user details
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: {
|
||||
_id: userId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User has no email address"),
|
||||
);
|
||||
}
|
||||
|
||||
// Find user in SCIM provider first
|
||||
const scimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email.toString(),
|
||||
);
|
||||
|
||||
if (scimUser) {
|
||||
// Remove user from SCIM provider (deactivate instead of delete to preserve audit trail)
|
||||
await SCIMUtil.deactivateUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser.id!,
|
||||
);
|
||||
logger.info(`Deactivated user ${user.email} in SCIM provider`);
|
||||
} else {
|
||||
logger.warn(`User ${user.email} not found in SCIM provider, proceeding with local removal only`);
|
||||
}
|
||||
|
||||
// Remove user from all teams in this project
|
||||
const teamMembers = await TeamMemberService.findBy({
|
||||
query: {
|
||||
userId: user.id!,
|
||||
projectId: projectScim.projectId!,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
await TeamMemberService.deleteOneById({
|
||||
id: teamMember.id!,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Removed user ${user.email} from ${teamMembers.length} teams in project`);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
success: true,
|
||||
message: "User deprovisioned successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -179,6 +179,7 @@ import ProjectUser from "./ProjectUser";
|
||||
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
|
||||
import MonitorFeed from "./MonitorFeed";
|
||||
import MetricType from "./MetricType";
|
||||
import ProjectSCIM from "./ProjectScim";
|
||||
|
||||
const AllModelTypes: Array<{
|
||||
new (): BaseModel;
|
||||
@@ -380,6 +381,8 @@ const AllModelTypes: Array<{
|
||||
MetricType,
|
||||
|
||||
OnCallDutyPolicyTimeLog,
|
||||
|
||||
ProjectSCIM
|
||||
];
|
||||
|
||||
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};
|
||||
|
||||
549
Common/Models/DatabaseModels/ProjectScim.ts
Normal file
549
Common/Models/DatabaseModels/ProjectScim.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import Project from "./Project";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import URL from "../../Types/API/URL";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/project-scim"))
|
||||
@TableMetadata({
|
||||
tableName: "ProjectSCIM",
|
||||
singularName: "SCIM",
|
||||
pluralName: "SCIM",
|
||||
icon: IconProp.User,
|
||||
tableDescription: "Manage SCIM user provisioning for your project",
|
||||
})
|
||||
@Entity({
|
||||
name: "ProjectSCIM",
|
||||
})
|
||||
export default class ProjectSCIM extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Any friendly name of this object",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
@UniqueColumnBy("projectId")
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongText,
|
||||
canReadOnRelationQuery: true,
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongURL,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "SCIM Base URL",
|
||||
description: "Base URL for SCIM server (e.g., https://yourapp.scim.com/v2)",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongURL,
|
||||
transformer: URL.getDatabaseTransformer(),
|
||||
})
|
||||
@UniqueColumnBy("projectId")
|
||||
public scimBaseUrl?: URL = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.VeryLongText,
|
||||
canReadOnRelationQuery: false,
|
||||
title: "Bearer Token",
|
||||
description: "Bearer token for authenticating with SCIM server",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.VeryLongText,
|
||||
})
|
||||
public bearerToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Team,
|
||||
title: "Default Teams",
|
||||
description: "Teams to add users to when they are provisioned via SCIM",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Team;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "ProjectScimTeam",
|
||||
inverseJoinColumn: {
|
||||
name: "teamId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "projectScimId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public teams?: Array<Team> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
title: "Enabled",
|
||||
description: "Is SCIM provisioning enabled for this project",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public isEnabled?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: true,
|
||||
title: "Auto Provision Users",
|
||||
description: "Automatically create users when they are provisioned via SCIM",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoProvisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
title: "Auto Deprovision Users",
|
||||
description: "Automatically remove users when they are deprovisioned via SCIM",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public autoDeprovisionUsers?: boolean = undefined;
|
||||
|
||||
// Is this integration tested?
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Tested",
|
||||
description: "Has this SCIM integration been tested?",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public isTested?: boolean = undefined;
|
||||
}
|
||||
476
Common/Server/API/ProjectSCIM.ts
Normal file
476
Common/Server/API/ProjectSCIM.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import ProjectScimService, {
|
||||
Service as ProjectScimServiceType,
|
||||
} from "../Services/ProjectScimService";
|
||||
import { ExpressRequest, ExpressResponse } from "../Utils/Express";
|
||||
import Response from "../Utils/Response";
|
||||
import BaseAPI from "./BaseAPI";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import ProjectSCIM from "../../Models/DatabaseModels/ProjectScim";
|
||||
import URL from "../../Types/API/URL";
|
||||
import BadRequestException from "../../Types/Exception/BadRequestException";
|
||||
import Exception from "../../Types/Exception/Exception";
|
||||
import UserService from "../Services/UserService";
|
||||
import TeamMemberService from "../Services/TeamMemberService";
|
||||
import User from "../../Models/DatabaseModels/User";
|
||||
import Team from "../../Models/DatabaseModels/Team";
|
||||
import TeamMember from "../../Models/DatabaseModels/TeamMember";
|
||||
import Select from "../Types/Database/Select";
|
||||
import Email from "../../Types/Email";
|
||||
import Name from "../../Types/Name";
|
||||
import logger from "../Utils/Logger";
|
||||
import SCIMUtil from "../Utils/SCIM";
|
||||
|
||||
export default class ProjectScimAPI extends BaseAPI<
|
||||
ProjectSCIM,
|
||||
ProjectScimServiceType
|
||||
> {
|
||||
public constructor() {
|
||||
super(ProjectSCIM, ProjectScimService);
|
||||
|
||||
// SCIM Fetch API
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectId/scim-list`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
const projectId: ObjectID = new ObjectID(
|
||||
req.params["projectId"] as string,
|
||||
);
|
||||
|
||||
if (!projectId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid project id."),
|
||||
);
|
||||
}
|
||||
|
||||
const scim: Array<ProjectSCIM> = await this.service.findBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
isEnabled: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
select: {
|
||||
name: true,
|
||||
description: true,
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.sendEntityArrayResponse(
|
||||
req,
|
||||
res,
|
||||
scim,
|
||||
new PositiveNumber(scim.length),
|
||||
ProjectSCIM,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Test SCIM connection
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/test-connection`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const scimBaseUrl: string = req.body["scimBaseUrl"];
|
||||
const bearerToken: string = req.body["bearerToken"];
|
||||
|
||||
if (!scimBaseUrl || !bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM Base URL and Bearer Token are required"),
|
||||
);
|
||||
}
|
||||
|
||||
const isConnected: boolean = await SCIMUtil.testConnection(
|
||||
URL.fromString(scimBaseUrl),
|
||||
bearerToken,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
isConnected,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sync users from SCIM provider
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectScimId/sync-users`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"] as string);
|
||||
|
||||
const projectScim: ProjectSCIM | null = await this.service.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
teams: {
|
||||
_id: true,
|
||||
name: true,
|
||||
} as Select<Team>,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get users from SCIM provider
|
||||
const scimUsers = await SCIMUtil.listUsers(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
);
|
||||
|
||||
const syncResults = {
|
||||
totalScimUsers: scimUsers.totalResults,
|
||||
processedUsers: 0,
|
||||
createdUsers: 0,
|
||||
updatedUsers: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Process each SCIM user
|
||||
for (const scimUser of scimUsers.Resources) {
|
||||
try {
|
||||
syncResults.processedUsers++;
|
||||
|
||||
const primaryEmail = scimUser.emails?.[0];
|
||||
if (!primaryEmail) {
|
||||
syncResults.errors.push(`User ${scimUser.userName} has no email address`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const email = new Email(primaryEmail.value);
|
||||
|
||||
// Check if user exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email },
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user && projectScim.autoProvisionUsers) {
|
||||
// Update existing user if displayName is provided
|
||||
if (scimUser.displayName) {
|
||||
await UserService.updateOneById({
|
||||
id: user.id!,
|
||||
data: {
|
||||
name: new Name(scimUser.displayName),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
syncResults.updatedUsers++;
|
||||
} else if (!user && projectScim.autoProvisionUsers) {
|
||||
// Create new user
|
||||
const newUser = new User();
|
||||
newUser.name = new Name(scimUser.displayName || scimUser.userName);
|
||||
newUser.email = email;
|
||||
newUser.isEmailVerified = true;
|
||||
|
||||
const createdUser = await UserService.create({
|
||||
data: newUser,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (createdUser) {
|
||||
syncResults.createdUsers++;
|
||||
|
||||
// Add user to configured teams if any
|
||||
if (projectScim.teams && projectScim.teams.length > 0) {
|
||||
for (const team of projectScim.teams) {
|
||||
try {
|
||||
// Create a new TeamMember instance
|
||||
// Note: Using regular import to avoid dynamic import issues
|
||||
const newTeamMember = new TeamMember();
|
||||
newTeamMember.teamId = team.id!;
|
||||
newTeamMember.userId = createdUser.id!;
|
||||
newTeamMember.projectId = projectScim.projectId!;
|
||||
newTeamMember.hasAcceptedInvitation = true;
|
||||
|
||||
await TeamMemberService.create({
|
||||
data: newTeamMember,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (teamErr) {
|
||||
logger.error(`Failed to add user ${email.toString()} to team ${team.name}: ${teamErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (userErr) {
|
||||
syncResults.errors.push(`Error processing user ${scimUser.userName}: ${userErr}`);
|
||||
logger.error(`SCIM user sync error: ${userErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, syncResults);
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Provision user to SCIM provider
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectScimId/provision-user`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"] as string);
|
||||
const userId: ObjectID = new ObjectID(req.body["userId"]);
|
||||
|
||||
const projectScim: ProjectSCIM | null = await this.service.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoProvisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto provisioning is disabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
const user: User | null = await UserService.findOneById({
|
||||
id: userId,
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists in SCIM provider
|
||||
const existingScimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email?.toString() || "",
|
||||
);
|
||||
|
||||
if (existingScimUser) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User already exists in SCIM provider"),
|
||||
);
|
||||
}
|
||||
|
||||
// Create SCIM user
|
||||
const scimUser = SCIMUtil.convertUserToSCIMUser(user);
|
||||
const createdScimUser = await SCIMUtil.createUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
scimUserId: createdScimUser.id,
|
||||
success: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Deprovision user from SCIM provider
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectScimId/deprovision-user`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"] as string);
|
||||
const userId: ObjectID = new ObjectID(req.body["userId"]);
|
||||
|
||||
const projectScim: ProjectSCIM | null = await this.service.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoDeprovisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto deprovisioning is disabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
const user: User | null = await UserService.findOneById({
|
||||
id: userId,
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Find user in SCIM provider
|
||||
const scimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email?.toString() || "",
|
||||
);
|
||||
|
||||
if (!scimUser) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found in SCIM provider"),
|
||||
);
|
||||
}
|
||||
|
||||
// Deactivate or delete the user based on preference
|
||||
if (req.body["deleteUser"]) {
|
||||
await SCIMUtil.deleteUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser.id!,
|
||||
);
|
||||
} else {
|
||||
await SCIMUtil.deactivateUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser.id!,
|
||||
);
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
success: true,
|
||||
action: req.body["deleteUser"] ? "deleted" : "deactivated",
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754227633113 implements MigrationInterface {
|
||||
public name = 'MigrationName1754227633113'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "ProjectSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying NOT NULL, "scimBaseUrl" text NOT NULL, "bearerToken" text NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "isEnabled" boolean NOT NULL DEFAULT false, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT false, "isTested" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_51e71d70211675a5c918aee4e68" PRIMARY KEY ("_id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f916360335859c26c4d7051239" ON "ProjectSCIM" ("projectId") `);
|
||||
await queryRunner.query(`CREATE TABLE "ProjectScimTeam" ("projectScimId" uuid NOT NULL, "teamId" uuid NOT NULL, CONSTRAINT "PK_db724b66b4fa8c880ce5ccf820b" PRIMARY KEY ("projectScimId", "teamId"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_b9a28efd66600267f0e9de0731" ON "ProjectScimTeam" ("projectScimId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_bb0eda2ef0c773f975e9ad8448" ON "ProjectScimTeam" ("teamId") `);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_f916360335859c26c4d7051239b" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_5d5d587984f156e5215d51daff7" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_b9a28efd66600267f0e9de0731b" FOREIGN KEY ("projectScimId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_b9a28efd66600267f0e9de0731b"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_5d5d587984f156e5215d51daff7"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_f916360335859c26c4d7051239b"`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_bb0eda2ef0c773f975e9ad8448"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_b9a28efd66600267f0e9de0731"`);
|
||||
await queryRunner.query(`DROP TABLE "ProjectScimTeam"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_f916360335859c26c4d7051239"`);
|
||||
await queryRunner.query(`DROP TABLE "ProjectSCIM"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -146,6 +146,7 @@ import { MigrationName1753343522987 } from "./1753343522987-MigrationName";
|
||||
import { MigrationName1753377161288 } from "./1753377161288-MigrationName";
|
||||
import { AddPerformanceIndexes1753378524062 } from "./1753378524062-AddPerformanceIndexes";
|
||||
import { MigrationName1753383711511 } from "./1753383711511-MigrationName";
|
||||
import { MigrationName1754227633113 } from "./1754227633113-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -296,4 +297,5 @@ export default [
|
||||
MigrationName1753377161288,
|
||||
AddPerformanceIndexes1753378524062,
|
||||
MigrationName1753383711511,
|
||||
MigrationName1754227633113
|
||||
];
|
||||
|
||||
10
Common/Server/Services/ProjectScimService.ts
Normal file
10
Common/Server/Services/ProjectScimService.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/ProjectScim";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
528
Common/Server/Utils/SCIM.ts
Normal file
528
Common/Server/Utils/SCIM.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import URL from "../../Types/API/URL";
|
||||
import Email from "../../Types/Email";
|
||||
import ServerException from "../../Types/Exception/ServerException";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import Name from "../../Types/Name";
|
||||
import logger from "./Logger";
|
||||
import API from "../../Utils/API";
|
||||
import HTTPResponse from "../../Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
|
||||
import Headers from "../../Types/API/Headers";
|
||||
import Route from "../../Types/API/Route";
|
||||
|
||||
export interface SCIMUser {
|
||||
id?: string;
|
||||
userName: string;
|
||||
name?: {
|
||||
formatted?: string;
|
||||
familyName?: string;
|
||||
givenName?: string;
|
||||
};
|
||||
displayName?: string;
|
||||
emails: Array<{
|
||||
value: string;
|
||||
type?: string;
|
||||
primary?: boolean;
|
||||
}>;
|
||||
active: boolean;
|
||||
groups?: Array<{
|
||||
value: string;
|
||||
display?: string;
|
||||
}>;
|
||||
meta?: {
|
||||
resourceType: string;
|
||||
created?: string;
|
||||
lastModified?: string;
|
||||
location?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SCIMGroup {
|
||||
id?: string;
|
||||
displayName: string;
|
||||
members?: Array<{
|
||||
value: string;
|
||||
display?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
meta?: {
|
||||
resourceType: string;
|
||||
created?: string;
|
||||
lastModified?: string;
|
||||
location?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SCIMListResponse<T> {
|
||||
schemas: string[];
|
||||
totalResults: number;
|
||||
startIndex: number;
|
||||
itemsPerPage: number;
|
||||
Resources: T[];
|
||||
}
|
||||
|
||||
export interface SCIMError {
|
||||
schemas: string[];
|
||||
scimType?: string;
|
||||
detail: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export default class SCIMUtil {
|
||||
public static readonly SCIM_SCHEMAS = {
|
||||
CORE_USER: "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
CORE_GROUP: "urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||
LIST_RESPONSE: "urn:ietf:params:scim:api:messages:2.0:ListResponse",
|
||||
ERROR: "urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
PATCH_OP: "urn:ietf:params:scim:api:messages:2.0:PatchOp",
|
||||
};
|
||||
|
||||
public static createHeaders(bearerToken: string): Headers {
|
||||
return {
|
||||
"Content-Type": "application/scim+json",
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
Accept: "application/scim+json",
|
||||
};
|
||||
}
|
||||
|
||||
public static async createUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
user: Omit<SCIMUser, "id" | "meta">,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const userData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.CORE_USER],
|
||||
...user,
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.post<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route("/Users")),
|
||||
userData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to create SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user created: ${user.userName}`);
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to create SCIM user: ${user.userName} - ${error.message}`);
|
||||
throw new ServerException(`Failed to create SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
user: Partial<SCIMUser>,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const userData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.CORE_USER],
|
||||
...user,
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.put<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
userData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to update SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user updated: ${userId}`);
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to update SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to update SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async deleteUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.delete<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to delete SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user deleted: ${userId}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to delete SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to delete SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async deactivateUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const patchData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.PATCH_OP],
|
||||
Operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "active",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.patch<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
patchData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to deactivate SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user deactivated: ${userId}`);
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to deactivate SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to deactivate SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to get SCIM user");
|
||||
}
|
||||
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to get SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to get SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getUserByUserName(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userName: string,
|
||||
): Promise<SCIMUser | null> {
|
||||
try {
|
||||
const usersUrl = scimBaseUrl.addRoute(new Route("/Users"));
|
||||
usersUrl.addQueryParam("filter", `userName eq "${userName}"`);
|
||||
usersUrl.addQueryParam("count", "1");
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
usersUrl,
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to get SCIM user by username");
|
||||
}
|
||||
|
||||
const listResponse = response.data as unknown as SCIMListResponse<SCIMUser>;
|
||||
if (listResponse.totalResults > 0) {
|
||||
return listResponse.Resources[0] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to get SCIM user by username: ${userName} - ${error.message}`);
|
||||
throw new ServerException(`Failed to get SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async listUsers(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
options?: {
|
||||
startIndex?: number;
|
||||
count?: number;
|
||||
filter?: string;
|
||||
},
|
||||
): Promise<SCIMListResponse<SCIMUser>> {
|
||||
try {
|
||||
const usersUrl = scimBaseUrl.addRoute(new Route("/Users"));
|
||||
usersUrl.addQueryParam("startIndex", (options?.startIndex || 1).toString());
|
||||
usersUrl.addQueryParam("count", (options?.count || 100).toString());
|
||||
|
||||
if (options?.filter) {
|
||||
usersUrl.addQueryParam("filter", options.filter);
|
||||
}
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
usersUrl,
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to list SCIM users");
|
||||
}
|
||||
|
||||
return response.data as unknown as SCIMListResponse<SCIMUser>;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to list SCIM users - ${error.message}`);
|
||||
throw new ServerException(`Failed to list SCIM users: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async testConnection(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const usersUrl = scimBaseUrl.addRoute(new Route("/Users"));
|
||||
usersUrl.addQueryParam("count", "1");
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
usersUrl,
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error(`SCIM connection test failed: ${scimBaseUrl.toString()} - ${response.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`SCIM connection test successful: ${scimBaseUrl.toString()}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
logger.error(`SCIM connection test failed: ${scimBaseUrl.toString()} - ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static convertOneUptimeUserToSCIMUser(
|
||||
email: Email,
|
||||
name?: Name,
|
||||
isActive: boolean = true,
|
||||
): Omit<SCIMUser, "id" | "meta"> {
|
||||
const emailValue = email.toString();
|
||||
|
||||
const result: Omit<SCIMUser, "id" | "meta"> = {
|
||||
userName: emailValue,
|
||||
displayName: name?.toString() || emailValue,
|
||||
emails: [
|
||||
{
|
||||
value: emailValue,
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
active: isActive,
|
||||
};
|
||||
|
||||
if (name) {
|
||||
result.name = {
|
||||
formatted: name.toString(),
|
||||
givenName: name.toString().split(" ")[0] || "",
|
||||
familyName: name.toString().split(" ").slice(1).join(" ") || "",
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static extractEmailFromSCIMUser(scimUser: SCIMUser): Email | null {
|
||||
if (!scimUser.emails || scimUser.emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find primary email first
|
||||
const primaryEmail = scimUser.emails.find(email => email.primary);
|
||||
if (primaryEmail) {
|
||||
return new Email(primaryEmail.value);
|
||||
}
|
||||
|
||||
// Otherwise, use the first email
|
||||
return new Email(scimUser.emails[0]!.value);
|
||||
}
|
||||
|
||||
public static extractNameFromSCIMUser(scimUser: SCIMUser): Name | null {
|
||||
if (scimUser.name?.formatted) {
|
||||
return new Name(scimUser.name.formatted);
|
||||
}
|
||||
|
||||
if (scimUser.displayName) {
|
||||
return new Name(scimUser.displayName);
|
||||
}
|
||||
|
||||
if (scimUser.name?.givenName || scimUser.name?.familyName) {
|
||||
const fullName = `${scimUser.name.givenName || ""} ${scimUser.name.familyName || ""}`.trim();
|
||||
if (fullName) {
|
||||
return new Name(fullName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Group Operations
|
||||
public static async createGroup(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
group: Omit<SCIMGroup, "id" | "meta">,
|
||||
): Promise<SCIMGroup> {
|
||||
try {
|
||||
const groupData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.CORE_GROUP],
|
||||
...group,
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.post<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route("/Groups")),
|
||||
groupData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to create SCIM group");
|
||||
}
|
||||
|
||||
logger.info(`SCIM group created: ${group.displayName}`);
|
||||
return response.data as unknown as SCIMGroup;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to create SCIM group: ${group.displayName} - ${error.message}`);
|
||||
throw new ServerException(`Failed to create SCIM group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async addUserToGroup(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
userDisplayName?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const patchData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.PATCH_OP],
|
||||
Operations: [
|
||||
{
|
||||
op: "add",
|
||||
path: "members",
|
||||
value: [
|
||||
{
|
||||
value: userId,
|
||||
display: userDisplayName,
|
||||
type: "User",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.patch<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Groups/${groupId}`)),
|
||||
patchData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to add user to group");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user ${userId} added to group ${groupId}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to add SCIM user ${userId} to group ${groupId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to add user to group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async removeUserFromGroup(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const patchData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.PATCH_OP],
|
||||
Operations: [
|
||||
{
|
||||
op: "remove",
|
||||
path: `members[value eq "${userId}"]`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.patch<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Groups/${groupId}`)),
|
||||
patchData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to remove user from group");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user ${userId} removed from group ${groupId}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to remove SCIM user ${userId} from group ${groupId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to remove user from group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static convertUserToSCIMUser(user: any): Omit<SCIMUser, "id" | "meta"> {
|
||||
const firstName = user.name?.firstName || "";
|
||||
const lastName = user.name?.lastName || "";
|
||||
|
||||
return {
|
||||
userName: user.email?.toString() || "",
|
||||
emails: [
|
||||
{
|
||||
value: user.email?.toString() || "",
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
name: {
|
||||
formatted: `${firstName} ${lastName}`.trim() || user.name?.toString() || "",
|
||||
givenName: firstName,
|
||||
familyName: lastName,
|
||||
},
|
||||
displayName: user.name?.toString() || user.email?.toString() || "",
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -372,6 +372,11 @@ enum Permission {
|
||||
EditStatusPageSSO = "EditStatusPageSSO",
|
||||
ReadStatusPageSSO = "ReadStatusPageSSO",
|
||||
|
||||
CreateProjectSCIM = "CreateProjectSCIM",
|
||||
DeleteProjectSCIM = "DeleteProjectSCIM",
|
||||
EditProjectSCIM = "EditProjectSCIM",
|
||||
ReadProjectSCIM = "ReadProjectSCIM",
|
||||
|
||||
// Label Permissions (Owner + Admin Permission by default)
|
||||
CreateProjectLabel = "CreateProjectLabel",
|
||||
EditProjectLabel = "EditProjectLabel",
|
||||
|
||||
311
Dashboard/src/Pages/Settings/SCIM.tsx
Normal file
311
Dashboard/src/Pages/Settings/SCIM.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import TeamsElement from "../../Components/Team/TeamsElement";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import Banner from "Common/UI/Components/Banner/Banner";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectScim";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const SCIMPage: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
const [showSCIMConfigId, setShowSCIMConfigId] = useState<string>("");
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Banner
|
||||
openInNewTab={true}
|
||||
title="Need help with configuring SCIM?"
|
||||
description="Watch this guide to understand SCIM user provisioning"
|
||||
link={URL.fromString("https://docs.oneuptime.com/scim")}
|
||||
hideOnMobile={true}
|
||||
/>
|
||||
|
||||
<ModelTable<ProjectSCIM>
|
||||
modelType={ProjectSCIM}
|
||||
userPreferencesKey={"project-scim-table"}
|
||||
query={{
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
}}
|
||||
id="scim-table"
|
||||
name="Settings > SCIM Provisioning"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "SCIM (System for Cross-domain Identity Management)",
|
||||
description:
|
||||
"SCIM is a standard for automating the exchange of user identity information between systems. It enables automatic user provisioning and deprovisioning.",
|
||||
}}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "SCIM Configuration",
|
||||
id: "scim-config",
|
||||
},
|
||||
{
|
||||
title: "Provisioning Settings",
|
||||
id: "provisioning",
|
||||
},
|
||||
]}
|
||||
noItemsMessage={"No SCIM configuration found."}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
description: "Friendly name to help you remember this SCIM configuration.",
|
||||
placeholder: "Company SCIM",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: true,
|
||||
description: "Friendly description to help you remember.",
|
||||
placeholder: "SCIM integration for user provisioning",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
scimBaseUrl: true,
|
||||
},
|
||||
title: "SCIM Base URL",
|
||||
fieldType: FormFieldSchemaType.URL,
|
||||
required: true,
|
||||
description:
|
||||
"Base URL for your SCIM server endpoint (e.g., https://yourapp.scim.com/v2)",
|
||||
placeholder: "https://yourapp.scim.com/v2",
|
||||
stepId: "scim-config",
|
||||
disableSpellCheck: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
bearerToken: true,
|
||||
},
|
||||
title: "Bearer Token",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
required: true,
|
||||
description:
|
||||
"Bearer token for authenticating with your SCIM server",
|
||||
placeholder: "Enter your SCIM bearer token",
|
||||
stepId: "scim-config",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
description:
|
||||
"Automatically create users in OneUptime when they are provisioned via SCIM",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
stepId: "provisioning",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
description:
|
||||
"Automatically remove users from OneUptime when they are deprovisioned via SCIM",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
stepId: "provisioning",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
description:
|
||||
"You can test this first, before enabling it. To test, please save the config.",
|
||||
title: "Enabled",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
stepId: "provisioning",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
teams: true,
|
||||
},
|
||||
title: "Default Teams",
|
||||
description: "Add users to these teams when they are provisioned via SCIM",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Team,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
placeholder: "Select Teams",
|
||||
stepId: "provisioning",
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "Test Connection",
|
||||
buttonStyleType: ButtonStyleType.NORMAL,
|
||||
onClick: async (
|
||||
item: ProjectSCIM,
|
||||
onCompleteAction: VoidFunction,
|
||||
) => {
|
||||
setShowSCIMConfigId((item["_id"] as string) || "");
|
||||
onCompleteAction();
|
||||
},
|
||||
},
|
||||
]}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Enabled",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isTested: true,
|
||||
},
|
||||
title: "Tested",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
teams: {
|
||||
name: true,
|
||||
_id: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
title: "Default Teams",
|
||||
type: FieldType.Text,
|
||||
getElement: (item: ProjectSCIM): ReactElement => {
|
||||
return <TeamsElement teams={item["teams"] || []} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Enabled",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isTested: true,
|
||||
},
|
||||
title: "Tested",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title={`Test SCIM Integration`}
|
||||
description={
|
||||
<span>
|
||||
Before enabling SCIM for your organization, make sure to test the connection.
|
||||
You can use the "Test Connection" button above to verify your SCIM configuration is working properly.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{showSCIMConfigId && (
|
||||
<ConfirmModal
|
||||
title={`SCIM Configuration Test`}
|
||||
description={
|
||||
<div>
|
||||
<div>
|
||||
Use this to test your SCIM configuration before enabling it for your organization.
|
||||
The test will verify that OneUptime can connect to your SCIM server and retrieve user information.
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<strong>Note:</strong> Make sure your SCIM server is accessible and the bearer token is valid.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Test Connection"}
|
||||
onSubmit={() => {
|
||||
// TODO: Implement actual SCIM connection test
|
||||
setShowSCIMConfigId("");
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.PRIMARY}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCIMPage;
|
||||
@@ -398,6 +398,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => {
|
||||
},
|
||||
icon: IconProp.Lock,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "SCIM",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SETTINGS_SCIM] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.User,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -196,6 +196,10 @@ const SettingsSSO: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SSO");
|
||||
});
|
||||
const SettingsSCIM: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SCIM");
|
||||
});
|
||||
const SettingsSmsLog: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SmsLog");
|
||||
@@ -680,6 +684,18 @@ const SettingsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.SETTINGS_SCIM)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<SettingsSCIM
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.SETTINGS_SCIM] as Route}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.SETTINGS_INCIDENTS_SEVERITY,
|
||||
|
||||
@@ -209,6 +209,11 @@ export function getSettingsBreadcrumbs(path: string): Array<Link> | undefined {
|
||||
"Settings",
|
||||
"SSO",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.SETTINGS_SCIM, [
|
||||
"Project",
|
||||
"Settings",
|
||||
"SCIM",
|
||||
]),
|
||||
...BuildBreadcrumbLinksByTitles(PageMap.SETTINGS_DANGERZONE, [
|
||||
"Project",
|
||||
"Settings",
|
||||
|
||||
@@ -338,6 +338,9 @@ enum PageMap {
|
||||
// SSO.
|
||||
SETTINGS_SSO = "SETTINGS_SSO",
|
||||
|
||||
// SCIM.
|
||||
SETTINGS_SCIM = "SETTINGS_SCIM",
|
||||
|
||||
// Domains
|
||||
|
||||
SETTINGS_DOMAINS = "SETTINGS_DOMAINS",
|
||||
|
||||
@@ -244,6 +244,7 @@ export const SettingsRoutePath: Dictionary<string> = {
|
||||
[PageMap.SETTINGS_DOMAINS]: "domains",
|
||||
[PageMap.SETTINGS_FEATURE_FLAGS]: "feature-flags",
|
||||
[PageMap.SETTINGS_SSO]: "sso",
|
||||
[PageMap.SETTINGS_SCIM]: "scim",
|
||||
[PageMap.SETTINGS_TEAMS]: "teams",
|
||||
[PageMap.SETTINGS_USERS]: "users",
|
||||
[PageMap.SETTINGS_USER_VIEW]: `users/${RouteParams.ModelID}`,
|
||||
@@ -1702,6 +1703,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SETTINGS_SCIM]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/settings/${
|
||||
SettingsRoutePath[PageMap.SETTINGS_SCIM]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SETTINGS_TEAMS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/settings/${
|
||||
SettingsRoutePath[PageMap.SETTINGS_TEAMS]
|
||||
|
||||
Reference in New Issue
Block a user