mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4588 from Infisical/feat/ENG-3757
Add SAML group mapping
This commit is contained in:
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -623,6 +623,12 @@ export const registerRoutes = async (
|
||||
userDAL,
|
||||
userAliasDAL,
|
||||
samlConfigDAL,
|
||||
groupDAL,
|
||||
userGroupMembershipDAL,
|
||||
groupProjectDAL,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
projectKeyDAL,
|
||||
licenseService,
|
||||
tokenService,
|
||||
smtpService,
|
||||
|
||||
@@ -53,6 +53,13 @@ description: "Learn how to configure Google SAML for Infisical SSO."
|
||||
|
||||

|
||||
|
||||
<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`
|
||||
|
||||

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

|
||||
</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>
|
||||
|
||||
BIN
docs/images/sso/google-saml/group-membership-mapping.png
Normal file
BIN
docs/images/sso/google-saml/group-membership-mapping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 797 KiB |
BIN
docs/images/sso/google-saml/groups-attribute-mapping.png
Normal file
BIN
docs/images/sso/google-saml/groups-attribute-mapping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 388 KiB |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
"groups" 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user