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

+
+ 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`
+
+ 
+
+
+
+ Back in Infisical, under Organization Settings, enable **SAML Group Membership Mapping** in the **Single Sign-On (SSO)** tab.
+
+ 
+
+
+
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
+
+
+ )}