diff --git a/backend/src/db/migrations/20250926000000_saml-configs-group-sync-fields.ts b/backend/src/db/migrations/20250926000000_saml-configs-group-sync-fields.ts new file mode 100644 index 000000000..a183cbf33 --- /dev/null +++ b/backend/src/db/migrations/20250926000000_saml-configs-group-sync-fields.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync"); + + await knex.schema.alterTable(TableName.SamlConfig, (tb) => { + if (!hasEnableGroupSyncCol) { + tb.boolean("enableGroupSync").notNullable().defaultTo(false); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync"); + + await knex.schema.alterTable(TableName.SamlConfig, (t) => { + if (hasEnableGroupSyncCol) { + t.dropColumn("enableGroupSync"); + } + }); +} diff --git a/backend/src/db/schemas/saml-configs.ts b/backend/src/db/schemas/saml-configs.ts index 350e84492..de85653c2 100644 --- a/backend/src/db/schemas/saml-configs.ts +++ b/backend/src/db/schemas/saml-configs.ts @@ -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; diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index f8e371d01..2ee0d5016 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -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() }), 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() }) .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, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 31aaa70aa..a4bb321ad 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -1,6 +1,16 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; -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 +18,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 +36,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; userDAL: Pick< TUserDALFactory, - "create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId" + | "create" + | "findOne" + | "find" + | "transaction" + | "updateById" + | "findById" + | "findUserEncKeyByUserId" + | "findUserEncKeyByUserIdsBatch" >; userAliasDAL: Pick; orgDAL: Pick< @@ -41,6 +68,15 @@ type TSamlConfigServiceFactoryDep = { >; identityMetadataDAL: Pick; orgMembershipDAL: Pick; + groupDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership" + >; + groupProjectDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + projectKeyDAL: Pick; permissionService: Pick; licenseService: Pick; tokenService: Pick; @@ -54,6 +90,12 @@ export const samlConfigServiceFactory = ({ orgMembershipDAL, userDAL, userAliasDAL, + groupDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, permissionService, licenseService, tokenService, @@ -61,6 +103,114 @@ export const samlConfigServiceFactory = ({ identityMetadataDAL, kmsService }: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => { + 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(/[^a-z0-9]/g, "-")}-${Date.now()}`, + orgId, + role: OrgMembershipRole.Member, + 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 +221,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 +247,12 @@ export const samlConfigServiceFactory = ({ }); } + if (enableGroupSync && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) { + throw new BadRequestError({ + message: "Group sync is only supported for Google SAML SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -107,7 +264,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 +281,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 +306,21 @@ 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." + }); + } + + const updateQuery: TSamlConfigsUpdate = { + authProvider, + isActive, + lastUsed: null + }; + + if (enableGroupSync !== undefined) { + updateQuery.enableGroupSync = enableGroupSync; + } const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -250,7 +423,8 @@ export const samlConfigServiceFactory = ({ entryPoint, issuer, cert, - lastUsed: samlConfig.lastUsed + lastUsed: samlConfig.lastUsed, + enableGroupSync: samlConfig.enableGroupSync }; }; @@ -282,6 +456,10 @@ 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 shouldSyncGroups = !!(samlConfig?.enableGroupSync && groupsMetadata?.value); + let user: TUsers; if (userAlias) { user = await userDAL.transaction(async (tx) => { @@ -303,7 +481,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 +512,38 @@ export const samlConfigServiceFactory = ({ } } + if (shouldSyncGroups && metadata && foundUser.id && groupsMetadata?.value) { + let samlGroups: string[] = []; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(groupsMetadata.value); + 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 = groupsMetadata.value + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + } + + if (samlGroups.length > 0) { + await syncUserGroupMemberships({ + userId: foundUser.id, + orgId, + samlGroups, + tx + }); + } + } + return foundUser; }); } else { @@ -395,12 +605,11 @@ export const samlConfigServiceFactory = ({ orgId, role, roleId, - status: newUser.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: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, isActive: true }, tx ); - // Only update the membership to Accepted if the user account is already completed. } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { await orgDAL.updateMembershipById( orgMembership.id, @@ -425,6 +634,39 @@ export const samlConfigServiceFactory = ({ ); } } + + if (shouldSyncGroups && metadata && newUser.id && groupsMetadata?.value) { + let samlGroups: string[] = []; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(groupsMetadata.value); + 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 = groupsMetadata.value + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + } + + if (samlGroups.length > 0) { + await syncUserGroupMemberships({ + userId: newUser.id, + orgId, + samlGroups, + tx + }); + } + } + return newUser; }); } diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index f4ede04fa..bdf65b988 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -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; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2fb7e9ff7..5fec5c502 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -621,6 +621,12 @@ export const registerRoutes = async ( userDAL, userAliasDAL, samlConfigDAL, + groupDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, licenseService, tokenService, smtpService, diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx index 84888b2f9..f79998afa 100644 --- a/docs/documentation/platform/sso/google-saml.mdx +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -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) + + For group membership mapping (optional), you can also configure: + - **groups** -> **groups** (if you want to sync Google groups to Infisical groups) + + This requires setting up group claims in Google Workspace. See the Group Membership Mapping section below for details. + + Click **Finish**. @@ -90,6 +97,27 @@ description: "Learn how to configure Google SAML for Infisical SSO." +## SAML Group Membership Mapping + +Automatically sync Google Workspace group memberships to Infisical. + + + + 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) + + + + 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) + + + 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. diff --git a/docs/images/sso/google-saml/group-membership-mapping.png b/docs/images/sso/google-saml/group-membership-mapping.png new file mode 100644 index 000000000..eb7ff67ec Binary files /dev/null and b/docs/images/sso/google-saml/group-membership-mapping.png differ diff --git a/docs/images/sso/google-saml/groups-attribute-mapping.png b/docs/images/sso/google-saml/groups-attribute-mapping.png new file mode 100644 index 000000000..16a494c3c Binary files /dev/null and b/docs/images/sso/google-saml/groups-attribute-mapping.png differ diff --git a/frontend/src/hooks/api/ssoConfig/queries.tsx b/frontend/src/hooks/api/ssoConfig/queries.tsx index 17dda5c1d..92189f2c2 100644 --- a/frontend/src/hooks/api/ssoConfig/queries.tsx +++ b/frontend/src/hooks/api/ssoConfig/queries.tsx @@ -69,7 +69,8 @@ export const useUpdateSSOConfig = () => { isActive, entryPoint, issuer, - cert + cert, + enableGroupSync }: { organizationId: string; authProvider?: string; @@ -77,6 +78,7 @@ export const useUpdateSSOConfig = () => { entryPoint?: string; issuer?: string; cert?: string; + enableGroupSync?: boolean; }) => { const { data } = await apiRequest.patch("/api/v1/sso/config", { organizationId, @@ -84,7 +86,8 @@ export const useUpdateSSOConfig = () => { ...(isActive !== undefined ? { isActive } : {}), ...(entryPoint !== undefined ? { entryPoint } : {}), ...(issuer !== undefined ? { issuer } : {}), - ...(cert !== undefined ? { cert } : {}) + ...(cert !== undefined ? { cert } : {}), + ...(enableGroupSync !== undefined ? { enableGroupSync } : {}) }); return data; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 53e6cece7..58f34dc47 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -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(); @@ -53,6 +59,33 @@ export const OrgSSOSection = (): JSX.Element => { } }; + const handleSamlGroupManagement = async (value: boolean) => { + try { + if (!currentOrg?.id) return; + + if (!subscription?.samlSSO) { + handlePopUpOpen("upgradePlan"); + 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 +165,66 @@ export const OrgSSOSection = (): JSX.Element => { Allow members to authenticate into Infisical with SAML

+ {data && GROUP_SYNC_SUPPORTED_PROVIDERS.includes(data.authProvider) && ( +
+
+
+ SAML Group Membership Mapping + +

+ 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. +

+

+ To use this feature you must include group claims in the SAML response as a + "groups" attribute. +

+ + See your SAML provider docs for details. + +

+ + 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. +

+ + } + > + +
+
+ + {(isAllowed) => ( + handleSamlGroupManagement(value)} + isDisabled={!isAllowed} + /> + )} + +
+

+ Infisical will manage user group memberships based on the SAML provider +

+
+ )}