Merge pull request #4588 from Infisical/feat/ENG-3757

Add SAML group mapping
This commit is contained in:
carlosmonastyrski
2025-09-30 22:47:38 -03:00
committed by GitHub
12 changed files with 445 additions and 22 deletions

View File

@@ -0,0 +1,23 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync");
if (!hasEnableGroupSyncCol) {
await knex.schema.alterTable(TableName.SamlConfig, (tb) => {
tb.boolean("enableGroupSync").notNullable().defaultTo(false);
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync");
if (hasEnableGroupSyncCol) {
await knex.schema.alterTable(TableName.SamlConfig, (t) => {
t.dropColumn("enableGroupSync");
});
}
}

View File

@@ -28,7 +28,8 @@ export const SamlConfigsSchema = z.object({
lastUsed: z.date().nullable().optional(),
encryptedSamlEntryPoint: zodBuffer,
encryptedSamlIssuer: zodBuffer,
encryptedSamlCertificate: zodBuffer
encryptedSamlCertificate: zodBuffer,
enableGroupSync: z.boolean().default(false)
});
export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>;

View File

@@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
entryPoint: z.string(),
issuer: z.string(),
cert: z.string(),
lastUsed: z.date().nullable().optional()
lastUsed: z.date().nullable().optional(),
enableGroupSync: z.boolean().optional()
})
}
},
@@ -325,14 +326,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer),
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert)
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert),
enableGroupSync: z.boolean().optional().describe(SamlSso.CREATE_CONFIG.enableGroupSync)
}),
response: {
200: SanitizedSamlConfigSchema
}
},
handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req;
return server.services.saml.createSamlCfg({
@@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer,
entryPoint,
idpCert: cert,
enableGroupSync,
actor: permission.type,
actorId: permission.id,
actorAuthMethod: permission.authMethod,
@@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer),
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert)
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert),
enableGroupSync: z.boolean().optional().describe(SamlSso.UPDATE_CONFIG.enableGroupSync)
})
.partial()
.merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })),
@@ -381,7 +385,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req;
return server.services.saml.updateSamlCfg({
@@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer,
entryPoint,
idpCert: cert,
enableGroupSync,
actor: permission.type,
actorId: permission.id,
actorAuthMethod: permission.authMethod,

View File

@@ -1,6 +1,17 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability";
import { Knex } from "knex";
import RE2 from "re2";
import { OrgMembershipStatus, TableName, TSamlConfigs, TSamlConfigsUpdate, TUsers } from "@app/db/schemas";
import {
OrgMembershipRole,
OrgMembershipStatus,
TableName,
TGroups,
TSamlConfigs,
TSamlConfigsUpdate,
TUsers
} from "@app/db/schemas";
import { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto";
@@ -8,12 +19,16 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { LoginMethod } from "@app/services/super-admin/super-admin-types";
@@ -22,17 +37,30 @@ import { normalizeUsername } from "@app/services/user/user-fns";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TGroupDALFactory } from "../group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "../group/group-fns";
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TSamlConfigDALFactory } from "./saml-config-dal";
import { TSamlConfigServiceFactory } from "./saml-config-types";
import { SamlProviders, TSamlConfigServiceFactory } from "./saml-config-types";
// SAML providers that support group sync
const GROUP_SYNC_SUPPORTED_PROVIDERS = [SamlProviders.GOOGLE_SAML] as SamlProviders[];
type TSamlConfigServiceFactoryDep = {
samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
userDAL: Pick<
TUserDALFactory,
"create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId"
| "create"
| "findOne"
| "find"
| "transaction"
| "updateById"
| "findById"
| "findUserEncKeyByUserId"
| "findUserEncKeyByUserIdsBatch"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick<
@@ -41,6 +69,15 @@ type TSamlConfigServiceFactoryDep = {
>;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">;
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "find" | "transaction">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
@@ -54,6 +91,12 @@ export const samlConfigServiceFactory = ({
orgMembershipDAL,
userDAL,
userAliasDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
permissionService,
licenseService,
tokenService,
@@ -61,6 +104,139 @@ export const samlConfigServiceFactory = ({
identityMetadataDAL,
kmsService
}: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => {
const parseSamlGroups = (groupsValue: string): string[] => {
let samlGroups: string[] = [];
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(groupsValue);
if (Array.isArray(parsed)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
samlGroups = parsed;
} else if (typeof parsed === "string") {
samlGroups = parsed
.split(",")
.map((g) => g.trim())
.filter(Boolean);
}
} catch {
samlGroups = groupsValue
.split(",")
.map((g) => g.trim())
.filter(Boolean);
}
return samlGroups;
};
const syncUserGroupMemberships = async ({
userId,
orgId,
samlGroups,
tx
}: {
userId: string;
orgId: string;
samlGroups: string[];
tx?: Knex;
}) => {
const processGroupSync = async (transaction: Knex) => {
const currentGroupMemberships = await userGroupMembershipDAL.find(
{
userId
},
{ tx: transaction }
);
const orgGroups = await groupDAL.find({ orgId }, { tx: transaction });
const orgGroupsMap = new Map(orgGroups.map((g: TGroups) => [g.name, g]));
const orgGroupIds = new Set(orgGroups.map((g) => g.id));
const currentOrgGroupMemberships = currentGroupMemberships.filter((m) => orgGroupIds.has(m.groupId));
const currentGroupNames = new Set(
currentOrgGroupMemberships
.map((m) => {
const group = orgGroups.find((g) => g.id === m.groupId);
return group?.name;
})
.filter(Boolean)
);
const targetGroupNames = new Set(samlGroups);
const groupsToAdd = samlGroups.filter((groupName) => !currentGroupNames.has(groupName));
const groupsToRemove = Array.from(currentGroupNames).filter(
(groupName) => groupName && !targetGroupNames.has(groupName)
);
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToAdd) {
if (!orgGroupsMap.has(groupName)) {
const newGroup = await groupDAL.create(
{
name: groupName,
slug: `${groupName.toLowerCase().replace(new RE2("[^a-z0-9]", "g"), "-")}-${Date.now()}`,
orgId,
role: OrgMembershipRole.NoAccess,
roleId: null
},
transaction
);
orgGroupsMap.set(groupName, newGroup);
}
}
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToAdd) {
const group = orgGroupsMap.get(groupName);
if (group) {
try {
await addUsersToGroupByUserIds({
userIds: [userId],
group,
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: transaction
});
} catch (error) {
// Continue if user already in group
}
}
}
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToRemove) {
if (groupName) {
const group = orgGroupsMap.get(groupName);
if (group) {
try {
await removeUsersFromGroupByUserIds({
userIds: [userId],
group,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx: transaction
});
} catch (error) {
// Continue if user not in group
}
}
}
}
};
if (tx) {
await processGroupSync(tx);
} else {
await userDAL.transaction(processGroupSync);
}
};
const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({
idpCert,
actor,
@@ -71,7 +247,8 @@ export const samlConfigServiceFactory = ({
actorId,
isActive,
entryPoint,
authProvider
authProvider,
enableGroupSync
}) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso);
@@ -96,6 +273,18 @@ export const samlConfigServiceFactory = ({
});
}
if (enableGroupSync && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
throw new BadRequestError({
message: "Group sync is not supported for this SAML provider."
});
}
if (enableGroupSync && !plan.groups) {
throw new BadRequestError({
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
});
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
@@ -107,7 +296,8 @@ export const samlConfigServiceFactory = ({
isActive,
encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob,
encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).cipherTextBlob,
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob,
enableGroupSync: enableGroupSync || false
});
return samlConfig;
@@ -123,7 +313,8 @@ export const samlConfigServiceFactory = ({
issuer,
isActive,
entryPoint,
authProvider
authProvider,
enableGroupSync
}) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
@@ -147,7 +338,27 @@ export const samlConfigServiceFactory = ({
});
}
const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null };
if (enableGroupSync && authProvider && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
throw new BadRequestError({
message: "Group sync is not supported for this SAML provider."
});
}
if (enableGroupSync && !plan.groups) {
throw new BadRequestError({
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
});
}
const updateQuery: TSamlConfigsUpdate = {
authProvider,
isActive,
lastUsed: null
};
if (enableGroupSync !== undefined) {
updateQuery.enableGroupSync = enableGroupSync;
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
@@ -250,7 +461,8 @@ export const samlConfigServiceFactory = ({
entryPoint,
issuer,
cert,
lastUsed: samlConfig.lastUsed
lastUsed: samlConfig.lastUsed,
enableGroupSync: samlConfig.enableGroupSync
};
};
@@ -282,6 +494,12 @@ export const samlConfigServiceFactory = ({
const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
const samlConfig = await samlConfigDAL.findOne({ orgId });
const groupsMetadata = metadata?.find(({ key }) => key === "groups");
const plan = await licenseService.getPlan(orgId);
const shouldSyncGroups = !!samlConfig?.enableGroupSync && !!plan.groups;
let user: TUsers;
if (userAlias) {
user = await userDAL.transaction(async (tx) => {
@@ -303,7 +521,7 @@ export const samlConfigServiceFactory = ({
orgId,
role,
roleId,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
isActive: true
},
tx
@@ -334,6 +552,17 @@ export const samlConfigServiceFactory = ({
}
}
if (shouldSyncGroups && metadata && foundUser.id) {
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
await syncUserGroupMemberships({
userId: foundUser.id,
orgId,
samlGroups,
tx
});
}
return foundUser;
});
} else {
@@ -425,6 +654,18 @@ export const samlConfigServiceFactory = ({
);
}
}
if (shouldSyncGroups && metadata && newUser.id) {
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
await syncUserGroupMemberships({
userId: newUser.id,
orgId,
samlGroups,
tx
});
}
return newUser;
});
}

View File

@@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = {
entryPoint: string;
issuer: string;
idpCert: string;
enableGroupSync?: boolean;
} & TOrgPermission;
export type TUpdateSamlCfgDTO = Partial<{
@@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{
entryPoint: string;
issuer: string;
idpCert: string;
enableGroupSync?: boolean;
}> &
TOrgPermission;
@@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = {
issuer: string;
cert: string;
lastUsed: Date | null | undefined;
enableGroupSync?: boolean;
}>;
samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean;

View File

@@ -2872,7 +2872,9 @@ export const SamlSso = {
entryPoint:
"The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.",
issuer: "The SAML provider issuer URL or entity ID.",
cert: "The certificate to use for SAML authentication."
cert: "The certificate to use for SAML authentication.",
enableGroupSync:
"Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups."
},
CREATE_CONFIG: {
organizationId: "The ID of the organization to create the SAML config for.",
@@ -2881,7 +2883,9 @@ export const SamlSso = {
entryPoint:
"The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.",
issuer: "The SAML provider issuer URL or entity ID.",
cert: "The certificate to use for SAML authentication."
cert: "The certificate to use for SAML authentication.",
enableGroupSync:
"Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups."
}
};

View File

@@ -623,6 +623,12 @@ export const registerRoutes = async (
userDAL,
userAliasDAL,
samlConfigDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
licenseService,
tokenService,
smtpService,

View File

@@ -53,6 +53,13 @@ description: "Learn how to configure Google SAML for Infisical SSO."
![Google SAML attribute mapping](../../../images/sso/google-saml/attribute-mapping.png)
<Note>
If you want to sync Google groups to Infisical groups, you can also configure:
- **groups** -> **groups**
This requires setting up group claims in Google Workspace. See the [Group Membership Mapping](#saml-group-membership-mapping) section below for details.
</Note>
Click **Finish**.
</Step>
<Step title="Assign users in Google Workspace to the application">
@@ -90,6 +97,34 @@ description: "Learn how to configure Google SAML for Infisical SSO."
</Steps>
## SAML Group Membership Mapping
Automatically sync Google Workspace group memberships to Infisical.
<Steps>
<Step title="Add groups attribute mapping in Google">
In your Google Admin console SAML app, go to **Attribute mapping** and add:
- **Google groups**: Include all groups you want to include in the SAML claim. Only these groups will be synced to Infisical.
- **App attribute**: `groups`
![Google SAML groups attribute mapping](../../../images/sso/google-saml/groups-attribute-mapping.png)
</Step>
<Step title="Enable SAML Group Membership Mapping in Infisical">
Back in Infisical, under Organization Settings, enable **SAML Group Membership Mapping** in the **Single Sign-On (SSO)** tab.
![Google SAML group membership mapping](../../../images/sso/google-saml/group-membership-mapping.png)
</Step>
<Step title="Group synchronization on login">
Once configured, Google groups will now be automatically synchronized when users log in through SAML. Users will be added to or removed from Infisical groups based on their current Google group memberships.
</Step>
</Steps>
<Warning>
Group membership changes in the SAML provider only sync with Infisical when a user logs in via SAML. For example, if you remove a user from a group in the SAML provider, this change will not be reflected in Infisical until their next SAML login. To ensure this behavior, Infisical recommends enabling Enforce SAML SSO.
</Warning>
<Tip>
If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite SAML login.
</Tip>

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

View File

@@ -34,7 +34,8 @@ export const useCreateSSOConfig = () => {
isActive,
entryPoint,
issuer,
cert
cert,
enableGroupSync
}: {
organizationId: string;
authProvider: string;
@@ -42,6 +43,7 @@ export const useCreateSSOConfig = () => {
entryPoint: string;
issuer: string;
cert: string;
enableGroupSync?: boolean;
}) => {
const { data } = await apiRequest.post("/api/v1/sso/config", {
organizationId,
@@ -49,7 +51,8 @@ export const useCreateSSOConfig = () => {
isActive,
entryPoint,
issuer,
cert
cert,
...(enableGroupSync !== undefined ? { enableGroupSync } : {})
});
return data;
@@ -69,7 +72,8 @@ export const useUpdateSSOConfig = () => {
isActive,
entryPoint,
issuer,
cert
cert,
enableGroupSync
}: {
organizationId: string;
authProvider?: string;
@@ -77,6 +81,7 @@ export const useUpdateSSOConfig = () => {
entryPoint?: string;
issuer?: string;
cert?: string;
enableGroupSync?: boolean;
}) => {
const { data } = await apiRequest.patch("/api/v1/sso/config", {
organizationId,
@@ -84,7 +89,8 @@ export const useUpdateSSOConfig = () => {
...(isActive !== undefined ? { isActive } : {}),
...(entryPoint !== undefined ? { entryPoint } : {}),
...(issuer !== undefined ? { issuer } : {}),
...(cert !== undefined ? { cert } : {})
...(cert !== undefined ? { cert } : {}),
...(enableGroupSync !== undefined ? { enableGroupSync } : {})
});
return data;

View File

@@ -1,7 +1,10 @@
import { faInfoCircle, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import { Button, Switch, Tooltip } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -13,6 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { SSOModal } from "./SSOModal";
// Auth providers that support group sync
const GROUP_SYNC_SUPPORTED_PROVIDERS = ["google-saml"] as const;
export const OrgSSOSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
@@ -21,6 +27,7 @@ export const OrgSSOSection = (): JSX.Element => {
const { mutateAsync } = useUpdateSSOConfig();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"upgradePlan",
"upgradeEnterprisePlan",
"addSSO"
] as const);
@@ -53,6 +60,33 @@ export const OrgSSOSection = (): JSX.Element => {
}
};
const handleSamlGroupManagement = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO || !subscription?.groups) {
handlePopUpOpen("upgradeEnterprisePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
enableGroupSync: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} SAML group membership mapping`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} SAML group membership mapping`,
type: "error"
});
}
};
const addSSOBtnClick = async () => {
try {
if (subscription?.samlSSO && currentOrg) {
@@ -132,6 +166,66 @@ export const OrgSSOSection = (): JSX.Element => {
Allow members to authenticate into Infisical with SAML
</p>
</div>
{data && GROUP_SYNC_SUPPORTED_PROVIDERS.includes(data.authProvider) && (
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="text-md flex items-center text-mineshaft-100">
<span>SAML Group Membership Mapping</span>
<Tooltip
className="max-w-lg"
content={
<>
<p>
When this feature is enabled, Infisical will automatically sync group
memberships between the SAML provider and Infisical. Users will be added to
Infisical groups that match their SAML group names.
</p>
<p className="mt-4">
To use this feature you must include group claims in the SAML response as a
&quot;groups&quot; attribute.
</p>
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview"
>
See your SAML provider docs for details.
</a>
<p className="mt-4 text-yellow">
<FontAwesomeIcon className="mr-1" icon={faWarning} />
Group membership changes in the SAML provider only sync with Infisical when a
user logs in via SAML. For example, if you remove a user from a group in the
SAML provider, this change will not be reflected in Infisical until their next
SAML login. To ensure this behavior, Infisical recommends enabling Enforce
SAML SSO.
</p>
</>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="ml-1 mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-saml-group-sync"
isChecked={data?.enableGroupSync ?? false}
onCheckedChange={(value) => handleSamlGroupManagement(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Infisical will manage user group memberships based on the SAML provider
</p>
</div>
)}
<SSOModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
@@ -142,6 +236,11 @@ export const OrgSSOSection = (): JSX.Element => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SAML SSO if you switch to Infisical's Pro plan."
/>
<UpgradePlanModal
isOpen={popUp.upgradeEnterprisePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradeEnterprisePlan", isOpen)}
text="You can use SAML group mapping if you switch to Infisical's Enterprise plan."
/>
</div>
);
};