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..4b637e809
--- /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");
+
+ if (!hasEnableGroupSyncCol) {
+ await knex.schema.alterTable(TableName.SamlConfig, (tb) => {
+ tb.boolean("enableGroupSync").notNullable().defaultTo(false);
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync");
+
+ if (hasEnableGroupSyncCol) {
+ await knex.schema.alterTable(TableName.SamlConfig, (t) => {
+ 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..76bff60e8 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().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,
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..f1ee313e8 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,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;
userDAL: Pick<
TUserDALFactory,
- "create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId"
+ | "create"
+ | "findOne"
+ | "find"
+ | "transaction"
+ | "updateById"
+ | "findById"
+ | "findUserEncKeyByUserId"
+ | "findUserEncKeyByUserIdsBatch"
>;
userAliasDAL: Pick;
orgDAL: Pick<
@@ -41,6 +69,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 +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;
});
}
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/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index 2ac8f996e..2d0de59a2 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -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."
}
};
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index f6c70884c..7c806674a 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -623,6 +623,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..639a11321 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."

+
+ 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.
+
+
Click **Finish**.
@@ -90,6 +97,34 @@ 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`
+
+ 
+
+
+
+ Back in Infisical, under Organization Settings, enable **SAML Group Membership Mapping** in the **Single Sign-On (SSO)** tab.
+
+ 
+
+
+ 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.
+
+
+
+
+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.
+
+
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..3ee5ec1b1 100644
--- a/frontend/src/hooks/api/ssoConfig/queries.tsx
+++ b/frontend/src/hooks/api/ssoConfig/queries.tsx
@@ -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;
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx
index 53e6cece7..d95a2f474 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();
@@ -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
+ {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
+
+
+ )}
{
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SAML SSO if you switch to Infisical's Pro plan."
/>
+ handlePopUpToggle("upgradeEnterprisePlan", isOpen)}
+ text="You can use SAML group mapping if you switch to Infisical's Enterprise plan."
+ />
);
};