Add SAML group mapping

This commit is contained in:
Carlos Monastyrski
2025-09-26 23:19:03 -03:00
parent 832b5f0f49
commit 9d970d3c54
11 changed files with 424 additions and 20 deletions
@@ -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");
await knex.schema.alterTable(TableName.SamlConfig, (tb) => {
if (!hasEnableGroupSyncCol) {
tb.boolean("enableGroupSync").notNullable().defaultTo(false);
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync");
await knex.schema.alterTable(TableName.SamlConfig, (t) => {
if (hasEnableGroupSyncCol) {
t.dropColumn("enableGroupSync");
}
});
}
+2 -1
View File
@@ -28,7 +28,8 @@ export const SamlConfigsSchema = z.object({
lastUsed: z.date().nullable().optional(), lastUsed: z.date().nullable().optional(),
encryptedSamlEntryPoint: zodBuffer, encryptedSamlEntryPoint: zodBuffer,
encryptedSamlIssuer: zodBuffer, encryptedSamlIssuer: zodBuffer,
encryptedSamlCertificate: zodBuffer encryptedSamlCertificate: zodBuffer,
enableGroupSync: z.boolean().default(false)
}); });
export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>; export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>;
+10 -5
View File
@@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
entryPoint: z.string(), entryPoint: z.string(),
issuer: z.string(), issuer: z.string(),
cert: 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), isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint), entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer), 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: { response: {
200: SanitizedSamlConfigSchema 200: SanitizedSamlConfigSchema
} }
}, },
handler: async (req) => { handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body; const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req; const { permission } = req;
return server.services.saml.createSamlCfg({ return server.services.saml.createSamlCfg({
@@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer, issuer,
entryPoint, entryPoint,
idpCert: cert, idpCert: cert,
enableGroupSync,
actor: permission.type, actor: permission.type,
actorId: permission.id, actorId: permission.id,
actorAuthMethod: permission.authMethod, actorAuthMethod: permission.authMethod,
@@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive), isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint), entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer), 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() .partial()
.merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })), .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) => { handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body; const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req; const { permission } = req;
return server.services.saml.updateSamlCfg({ return server.services.saml.updateSamlCfg({
@@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer, issuer,
entryPoint, entryPoint,
idpCert: cert, idpCert: cert,
enableGroupSync,
actor: permission.type, actor: permission.type,
actorId: permission.id, actorId: permission.id,
actorAuthMethod: permission.authMethod, actorAuthMethod: permission.authMethod,
@@ -1,6 +1,16 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability"; 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 { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto"; 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 { AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types"; 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 { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; 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 { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { LoginMethod } from "@app/services/super-admin/super-admin-types"; 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 { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types"; 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 { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TSamlConfigDALFactory } from "./saml-config-dal"; 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 = { type TSamlConfigServiceFactoryDep = {
samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">; samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
userDAL: Pick< userDAL: Pick<
TUserDALFactory, TUserDALFactory,
"create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId" | "create"
| "findOne"
| "find"
| "transaction"
| "updateById"
| "findById"
| "findUserEncKeyByUserId"
| "findUserEncKeyByUserIdsBatch"
>; >;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">; userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick< orgDAL: Pick<
@@ -41,6 +68,15 @@ type TSamlConfigServiceFactoryDep = {
>; >;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">; identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">; 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">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">; licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">; tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
@@ -54,6 +90,12 @@ export const samlConfigServiceFactory = ({
orgMembershipDAL, orgMembershipDAL,
userDAL, userDAL,
userAliasDAL, userAliasDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
permissionService, permissionService,
licenseService, licenseService,
tokenService, tokenService,
@@ -61,6 +103,114 @@ export const samlConfigServiceFactory = ({
identityMetadataDAL, identityMetadataDAL,
kmsService kmsService
}: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => { }: 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 ({ const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({
idpCert, idpCert,
actor, actor,
@@ -71,7 +221,8 @@ export const samlConfigServiceFactory = ({
actorId, actorId,
isActive, isActive,
entryPoint, entryPoint,
authProvider authProvider,
enableGroupSync
}) => { }) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); 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({ const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.Organization,
orgId orgId
@@ -107,7 +264,8 @@ export const samlConfigServiceFactory = ({
isActive, isActive,
encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob, encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob,
encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).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; return samlConfig;
@@ -123,7 +281,8 @@ export const samlConfigServiceFactory = ({
issuer, issuer,
isActive, isActive,
entryPoint, entryPoint,
authProvider authProvider,
enableGroupSync
}) => { }) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); 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({ const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.Organization,
orgId orgId
@@ -250,7 +423,8 @@ export const samlConfigServiceFactory = ({
entryPoint, entryPoint,
issuer, issuer,
cert, cert,
lastUsed: samlConfig.lastUsed lastUsed: samlConfig.lastUsed,
enableGroupSync: samlConfig.enableGroupSync
}; };
}; };
@@ -282,6 +456,10 @@ export const samlConfigServiceFactory = ({
const organization = await orgDAL.findOrgById(orgId); const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); 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; let user: TUsers;
if (userAlias) { if (userAlias) {
user = await userDAL.transaction(async (tx) => { user = await userDAL.transaction(async (tx) => {
@@ -303,7 +481,7 @@ export const samlConfigServiceFactory = ({
orgId, orgId,
role, role,
roleId, 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 isActive: true
}, },
tx 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; return foundUser;
}); });
} else { } else {
@@ -395,12 +605,11 @@ export const samlConfigServiceFactory = ({
orgId, orgId,
role, role,
roleId, 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 isActive: true
}, },
tx tx
); );
// Only update the membership to Accepted if the user account is already completed.
} else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) {
await orgDAL.updateMembershipById( await orgDAL.updateMembershipById(
orgMembership.id, 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; return newUser;
}); });
} }
@@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = {
entryPoint: string; entryPoint: string;
issuer: string; issuer: string;
idpCert: string; idpCert: string;
enableGroupSync?: boolean;
} & TOrgPermission; } & TOrgPermission;
export type TUpdateSamlCfgDTO = Partial<{ export type TUpdateSamlCfgDTO = Partial<{
@@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{
entryPoint: string; entryPoint: string;
issuer: string; issuer: string;
idpCert: string; idpCert: string;
enableGroupSync?: boolean;
}> & }> &
TOrgPermission; TOrgPermission;
@@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = {
issuer: string; issuer: string;
cert: string; cert: string;
lastUsed: Date | null | undefined; lastUsed: Date | null | undefined;
enableGroupSync?: boolean;
}>; }>;
samlLogin: (arg: TSamlLoginDTO) => Promise<{ samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean; isUserCompleted: boolean;
+6
View File
@@ -621,6 +621,12 @@ export const registerRoutes = async (
userDAL, userDAL,
userAliasDAL, userAliasDAL,
samlConfigDAL, samlConfigDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
licenseService, licenseService,
tokenService, tokenService,
smtpService, smtpService,
@@ -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) ![Google SAML attribute mapping](../../../images/sso/google-saml/attribute-mapping.png)
<Note>
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.
</Note>
Click **Finish**. Click **Finish**.
</Step> </Step>
<Step title="Assign users in Google Workspace to the application"> <Step title="Assign users in Google Workspace to the application">
@@ -90,6 +97,27 @@ description: "Learn how to configure Google SAML for Infisical SSO."
</Steps> </Steps>
## SAML Group Membership Mapping
Automatically sync Google Workspace group memberships to Infisical.
<Steps>
<Step title="Add groups attribute mapping in Google">
In your Google Admin console SAML app, go to **Attribute mapping** and add:
- **Google groups**: Include all groups you want to include in the SAML claim. Only these groups will be synced to Infisical.
- **App attribute**: `groups`
![Google SAML groups attribute mapping](../../../images/sso/google-saml/groups-attribute-mapping.png)
</Step>
<Step title="Enable SAML Group Membership Mapping in Infisical">
Back in Infisical, under Organization Settings, enable **SAML Group Membership Mapping** in the **Single Sign-On (SSO)** tab.
![Google SAML group membership mapping](../../../images/sso/google-saml/group-membership-mapping.png)
</Step>
</Steps>
<Tip> <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. 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> </Tip>
Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

+5 -2
View File
@@ -69,7 +69,8 @@ export const useUpdateSSOConfig = () => {
isActive, isActive,
entryPoint, entryPoint,
issuer, issuer,
cert cert,
enableGroupSync
}: { }: {
organizationId: string; organizationId: string;
authProvider?: string; authProvider?: string;
@@ -77,6 +78,7 @@ export const useUpdateSSOConfig = () => {
entryPoint?: string; entryPoint?: string;
issuer?: string; issuer?: string;
cert?: string; cert?: string;
enableGroupSync?: boolean;
}) => { }) => {
const { data } = await apiRequest.patch("/api/v1/sso/config", { const { data } = await apiRequest.patch("/api/v1/sso/config", {
organizationId, organizationId,
@@ -84,7 +86,8 @@ export const useUpdateSSOConfig = () => {
...(isActive !== undefined ? { isActive } : {}), ...(isActive !== undefined ? { isActive } : {}),
...(entryPoint !== undefined ? { entryPoint } : {}), ...(entryPoint !== undefined ? { entryPoint } : {}),
...(issuer !== undefined ? { issuer } : {}), ...(issuer !== undefined ? { issuer } : {}),
...(cert !== undefined ? { cert } : {}) ...(cert !== undefined ? { cert } : {}),
...(enableGroupSync !== undefined ? { enableGroupSync } : {})
}); });
return data; 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 { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2"; import { Button, Switch, Tooltip } from "@app/components/v2";
import { import {
OrgPermissionActions, OrgPermissionActions,
OrgPermissionSubjects, OrgPermissionSubjects,
@@ -13,6 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { SSOModal } from "./SSOModal"; import { SSOModal } from "./SSOModal";
// Auth providers that support group sync
const GROUP_SYNC_SUPPORTED_PROVIDERS = ["google-saml"] as const;
export const OrgSSOSection = (): JSX.Element => { export const OrgSSOSection = (): JSX.Element => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { subscription } = useSubscription(); 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 () => { const addSSOBtnClick = async () => {
try { try {
if (subscription?.samlSSO && currentOrg) { if (subscription?.samlSSO && currentOrg) {
@@ -132,6 +165,66 @@ export const OrgSSOSection = (): JSX.Element => {
Allow members to authenticate into Infisical with SAML Allow members to authenticate into Infisical with SAML
</p> </p>
</div> </div>
{data && GROUP_SYNC_SUPPORTED_PROVIDERS.includes(data.authProvider) && (
<div className="py-4">
<div className="mb-2 flex justify-between">
<div className="text-md flex items-center text-mineshaft-100">
<span>SAML Group Membership Mapping</span>
<Tooltip
className="max-w-lg"
content={
<>
<p>
When this feature is enabled, Infisical will automatically sync group
memberships between the SAML provider and Infisical. Users will be added to
Infisical groups that match their SAML group names.
</p>
<p className="mt-4">
To use this feature you must include group claims in the SAML response as a
&quot;groups&quot; attribute.
</p>
<a
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-mineshaft-300"
href="https://infisical.com/docs/documentation/platform/sso/overview"
>
See your SAML provider docs for details.
</a>
<p className="mt-4 text-yellow">
<FontAwesomeIcon className="mr-1" icon={faWarning} />
Group membership changes in the SAML provider only sync with Infisical when a
user logs in via SAML. For example, if you remove a user from a group in the
SAML provider, this change will not be reflected in Infisical until their next
SAML login. To ensure this behavior, Infisical recommends enabling Enforce
SAML SSO.
</p>
</>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="ml-1 mt-0.5 inline-block text-mineshaft-400"
/>
</Tooltip>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-saml-group-sync"
isChecked={data?.enableGroupSync ?? false}
onCheckedChange={(value) => handleSamlGroupManagement(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Infisical will manage user group memberships based on the SAML provider
</p>
</div>
)}
<SSOModal <SSOModal
popUp={popUp} popUp={popUp}
handlePopUpClose={handlePopUpClose} handlePopUpClose={handlePopUpClose}