feat: update SCIM integration to manage team members with Push Groups

This commit is contained in:
Nawaz Dhandala
2025-11-27 13:58:47 +00:00
parent dc041d924a
commit 4fec2caef6
5 changed files with 163 additions and 27 deletions

View File

@@ -44,10 +44,13 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
}
@CaptureSpan()
private async isSCIMEnabled(projectId: ObjectID): Promise<boolean> {
private async isSCIMPushGroupsEnabled(
projectId: ObjectID,
): Promise<boolean> {
const count: PositiveNumber = await ProjectSCIMService.countBy({
query: {
projectId: projectId,
enablePushGroups: true,
},
props: {
isRoot: true,
@@ -63,12 +66,12 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
// Check if SCIM is enabled for the project
if (
!createBy.props.isRoot &&
(await this.isSCIMEnabled(
(await this.isSCIMPushGroupsEnabled(
createBy.data.projectId! || createBy.props.tenantId,
))
) {
throw new BadDataException(
"Cannot invite team members when SCIM is enabled for this project.",
"Cannot invite team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.",
);
}
@@ -311,10 +314,10 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
!deleteBy.props.isRoot &&
members.length > 0 &&
members[0]?.projectId &&
(await this.isSCIMEnabled(members[0].projectId))
(await this.isSCIMPushGroupsEnabled(members[0].projectId))
) {
throw new BadDataException(
"Cannot delete team members when SCIM is enabled for this project.",
"Cannot delete team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.",
);
}
@@ -346,11 +349,10 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
});
// Skip the one-member guard when SCIM manages membership for the project.
const isSCIMEnabled: boolean = await this.isSCIMEnabled(
member.projectId!,
);
const isPushGroupsManaged: boolean =
await this.isSCIMPushGroupsEnabled(member.projectId!);
if (!isSCIMEnabled && membersInTeam.toNumber() <= 1) {
if (!isPushGroupsManaged && membersInTeam.toNumber() <= 1) {
throw new BadDataException(
Errors.TeamMemberService.ONE_MEMBER_REQUIRED,
);

View File

@@ -71,6 +71,7 @@ export class Service extends DatabaseService<Model> {
const scimCount: PositiveNumber = await ProjectSCIMService.countBy({
query: {
projectId: projectId,
enablePushGroups: true,
},
skip: new PositiveNumber(0),
limit: new PositiveNumber(1),
@@ -82,7 +83,7 @@ export class Service extends DatabaseService<Model> {
if (scimCount.toNumber() > 0) {
throw new BadDataException(
`Cannot ${data.action} teams when SCIM is enabled for this project.`,
`Cannot ${data.action} teams while SCIM Push Groups is enabled for this project. Disable Push Groups to manage teams from OneUptime.`,
);
}
}

View File

@@ -5,6 +5,7 @@ import MailService from "../../../Server/Services/MailService";
import TeamMemberService from "../../../Server/Services/TeamMemberService";
import UserNotificationRuleService from "../../../Server/Services/UserNotificationRuleService";
import UserNotificationSettingService from "../../../Server/Services/UserNotificationSettingService";
import ProjectSCIMService from "../../../Server/Services/ProjectSCIMService";
import Errors from "../../../Server/Utils/Errors";
import "../TestingUtils/Init";
import ProjectServiceHelper from "../TestingUtils/Services/ProjectServiceHelper";
@@ -334,6 +335,119 @@ describe("TeamMemberService", () => {
},
);
});
it("should block inviting users when SCIM push groups is enabled", async () => {
const owner: User = await UserServiceHelper.genrateAndSaveRandomUser(
null,
{
isRoot: true,
},
);
const project: Project =
await ProjectServiceHelper.generateAndSaveRandomProject(null, {
isRoot: true,
userId: owner.id!,
});
const team: Team = await TeamServiceHelper.generateAndSaveRandomTeam(
{
projectId: new ObjectID(project.id!),
},
{
isRoot: true,
},
);
const memberUser: User =
await UserServiceHelper.genrateAndSaveRandomUser(null, {
isRoot: true,
});
await ProjectSCIMService.create({
data: {
projectId: new ObjectID(project._id!),
name: "Test SCIM Push Groups",
bearerToken: ObjectID.generate().toString(),
enablePushGroups: true,
},
props: {
isRoot: true,
},
});
const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember(
{
projectId: new ObjectID(project._id!),
userId: new ObjectID(memberUser._id!),
teamId: new ObjectID(team._id!),
},
);
await expect(
TeamMemberService.create({
data: tm,
props: { isRoot: false, tenantId: project.id! },
}),
).rejects.toThrow(/SCIM Push Groups/i);
});
it("should allow inviting users when SCIM push groups is disabled", async () => {
const owner: User = await UserServiceHelper.genrateAndSaveRandomUser(
null,
{
isRoot: true,
},
);
const project: Project =
await ProjectServiceHelper.generateAndSaveRandomProject(null, {
isRoot: true,
userId: owner.id!,
});
const team: Team = await TeamServiceHelper.generateAndSaveRandomTeam(
{
projectId: new ObjectID(project.id!),
},
{
isRoot: true,
},
);
const memberUser: User =
await UserServiceHelper.genrateAndSaveRandomUser(null, {
isRoot: true,
});
await ProjectSCIMService.create({
data: {
projectId: new ObjectID(project._id!),
name: "Test SCIM without Push Groups",
bearerToken: ObjectID.generate().toString(),
enablePushGroups: false,
},
props: {
isRoot: true,
},
});
const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember(
{
projectId: new ObjectID(project._id!),
userId: new ObjectID(memberUser._id!),
teamId: new ObjectID(team._id!),
},
);
const teamMember: TeamMember = await TeamMemberService.create({
data: tm,
props: { isRoot: false, tenantId: project.id! },
});
expect(teamMember).toBeDefined();
expect(teamMember.projectId?.toString()).toEqual(project._id?.toString());
});
});
describe("onCreateSuccess", () => {

View File

@@ -53,7 +53,8 @@ const TeamView: FunctionComponent<PageComponentProps> = (
const complianceStatusTableRef: React.Ref<TeamComplianceStatusTableRef> =
React.useRef<TeamComplianceStatusTableRef>(null);
const [isScimEnabled, setIsScimEnabled] = React.useState<boolean>(false);
const [isPushGroupsManaged, setIsPushGroupsManaged] =
React.useState<boolean>(false);
React.useEffect(() => {
const checkScim: () => Promise<void> = async () => {
@@ -65,9 +66,10 @@ const TeamView: FunctionComponent<PageComponentProps> = (
modelType: ProjectSCIM,
query: {
projectId: props.currentProject._id,
enablePushGroups: true,
},
});
setIsScimEnabled(scimCount > 0);
setIsPushGroupsManaged(scimCount > 0);
} catch {
// ignore
}
@@ -327,23 +329,30 @@ const TeamView: FunctionComponent<PageComponentProps> = (
{/* Team Members Table */}
{isPushGroupsManaged && (
<Banner
title="Team membership is managed by SCIM Push Groups"
description="Manage team members from your identity provider or disable Push Groups in Settings > SCIM to make changes here."
/>
)}
<ModelTable<TeamMember>
modelType={TeamMember}
id="table-team-member"
userPreferencesKey="team-member-table"
isDeleteable={true}
isDeleteable={!isPushGroupsManaged}
name="Settings > Team > Member"
createVerb={"Invite"}
isCreateable={true}
isCreateable={!isPushGroupsManaged}
isViewable={false}
query={{
teamId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
}}
onBeforeCreate={(item: TeamMember): Promise<TeamMember> => {
if (isScimEnabled) {
if (isPushGroupsManaged) {
throw new BadDataException(
"Cannot invite users when SCIM is enabled for this project.",
"Cannot invite users while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.",
);
}
if (!props.currentProject || !props.currentProject._id) {
@@ -354,9 +363,9 @@ const TeamView: FunctionComponent<PageComponentProps> = (
return Promise.resolve(item);
}}
onBeforeDelete={async (item: TeamMember): Promise<TeamMember> => {
if (isScimEnabled) {
if (isPushGroupsManaged) {
throw new BadDataException(
"Cannot remove team members when SCIM is enabled for this project.",
"Cannot remove team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.",
);
}
return item;

View File

@@ -21,6 +21,7 @@ import BadDataException from "Common/Types/Exception/BadDataException";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import Banner from "Common/UI/Components/Banner/Banner";
const Teams: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@@ -30,7 +31,8 @@ const Teams: FunctionComponent<PageComponentProps> = (
const [showScimErrorModal, setShowScimErrorModal] =
React.useState<boolean>(false);
const [isFilterApplied, setIsFilterApplied] = React.useState<boolean>(false);
const [isScimEnabled, setIsScimEnabled] = React.useState<boolean>(false);
const [isPushGroupsManaged, setIsPushGroupsManaged] =
React.useState<boolean>(false);
React.useEffect(() => {
const checkScim: () => Promise<void> = async () => {
@@ -42,9 +44,10 @@ const Teams: FunctionComponent<PageComponentProps> = (
modelType: ProjectSCIM,
query: {
projectId: props.currentProject._id,
enablePushGroups: true,
},
});
setIsScimEnabled(scimCount > 0);
setIsPushGroupsManaged(scimCount > 0);
} catch {
// ignore
}
@@ -54,12 +57,19 @@ const Teams: FunctionComponent<PageComponentProps> = (
return (
<Fragment>
{isPushGroupsManaged && (
<Banner
title="Users are managed by SCIM Push Groups"
description="Invite or remove users from your identity provider or disable Push Groups in Settings > SCIM to manage them here."
/>
)}
<ModelTable<TeamMember>
modelType={TeamMember}
id="teams-table"
name="Settings > Users"
userPreferencesKey="users-table"
isDeleteable={!isScimEnabled}
isDeleteable={!isPushGroupsManaged}
isEditable={false}
isCreateable={false}
onFilterApplied={(isApplied: boolean) => {
@@ -67,9 +77,9 @@ const Teams: FunctionComponent<PageComponentProps> = (
}}
isViewable={true}
onBeforeDelete={async (item: TeamMember): Promise<TeamMember> => {
if (isScimEnabled) {
if (isPushGroupsManaged) {
throw new BadDataException(
"Cannot remove team members when SCIM is enabled for this project.",
"Cannot remove team members while SCIM Push Groups is enabled for this project. Disable Push Groups to manage members from OneUptime.",
);
}
return item;
@@ -84,7 +94,7 @@ const Teams: FunctionComponent<PageComponentProps> = (
buttonStyle: ButtonStyleType.NORMAL,
icon: IconProp.Add,
onClick: () => {
if (isScimEnabled) {
if (isPushGroupsManaged) {
setShowScimErrorModal(true);
} else {
setShowInviteUserModal(true);
@@ -185,7 +195,7 @@ const Teams: FunctionComponent<PageComponentProps> = (
},
]}
/>
{showInviteUserModal && !isScimEnabled && (
{showInviteUserModal && !isPushGroupsManaged && (
<ModelFormModal<TeamMember>
modelType={TeamMember}
name="Invite New User"
@@ -250,8 +260,8 @@ const Teams: FunctionComponent<PageComponentProps> = (
)}
{showScimErrorModal && (
<ConfirmModal
title="Users are managed by SCIM"
description="Cannot invite users when SCIM is enabled for this project. User management is handled by your identity provider."
title="Users are managed by SCIM Push Groups"
description="Team membership is being managed by your identity provider. Disable Push Groups in Settings > SCIM if you need to invite or promote users from OneUptime."
onSubmit={() => {
setShowScimErrorModal(false);
}}