mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-05 20:26:44 +00:00
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");
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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."
|
|||||||
|
|
||||||

|

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

|
||||||
|
|
||||||
|
</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>
|
||||||
|
</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 |
@@ -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;
|
||||||
|
|||||||
+94
-1
@@ -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
|
||||||
|
"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
|
<SSOModal
|
||||||
popUp={popUp}
|
popUp={popUp}
|
||||||
handlePopUpClose={handlePopUpClose}
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
|||||||
Reference in New Issue
Block a user