Compare commits

...

8 Commits
master ... scim

Author SHA1 Message Date
Simon Larsen
e3f3bfcebb feat: Implement SCIM configuration page with user provisioning settings 2025-08-03 15:00:47 +01:00
Simon Larsen
0690417a54 feat: Add SCIM migration and model definitions for ProjectSCIM and ProjectScimTeam 2025-08-03 14:27:53 +01:00
Simon Larsen
bda70a24dc feat: Remove SCIM utility functions and refactor imports in ProjectSCIM API 2025-08-03 14:25:53 +01:00
Simon Larsen
32cdd4fe65 feat: Integrate TeamMemberService for user management in SCIM operations 2025-08-03 14:00:54 +01:00
Simon Larsen
4579db4c59 Merge branch 'master' into scim 2025-08-03 13:38:53 +01:00
Simon Larsen
010de82ccb refactor: Update SCIM API to improve user synchronization and deprovisioning logic 2025-07-22 15:05:05 +01:00
Simon Larsen
08d42c7923 Merge branch 'master' into scim 2025-07-22 14:32:58 +01:00
Simon Larsen
4f76afb9f2 feat: Implement SCIM integration for user and group management
- Added SCIM utility functions for creating, updating, deleting, and retrieving users and groups.
- Introduced ProjectSCIM model to manage SCIM configurations per project.
- Developed ProjectScimAPI to handle SCIM-related API endpoints including user synchronization and provisioning.
- Implemented ProjectScimService for database operations related to ProjectSCIM.
- Created SCIM settings page in the dashboard for managing SCIM configurations.
2025-07-21 20:12:31 +01:00
16 changed files with 2477 additions and 0 deletions

View File

@@ -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(),

View 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;

View File

@@ -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 } } = {};

View 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;
}

View 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);
}
},
);
}
}

View File

@@ -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"`);
}
}

View File

@@ -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
];

View 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
View 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,
};
}
}

View File

@@ -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",

View 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;

View File

@@ -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,
},
],
},
{

View File

@@ -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,

View File

@@ -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",

View File

@@ -338,6 +338,9 @@ enum PageMap {
// SSO.
SETTINGS_SSO = "SETTINGS_SSO",
// SCIM.
SETTINGS_SCIM = "SETTINGS_SCIM",
// Domains
SETTINGS_DOMAINS = "SETTINGS_DOMAINS",

View File

@@ -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]