diff --git a/backend/package-lock.json b/backend/package-lock.json index 98b15e5f5..b51573688 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -45,6 +45,7 @@ "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "knex": "^3.0.1", + "ldapjs": "^3.0.7", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", @@ -2510,6 +2511,83 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" + }, "node_modules/@lukeed/ms": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.1.tgz", @@ -9304,15 +9382,7 @@ "node": ">=0.8.0" } }, - "node_modules/ldapauth-fork/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/ldapjs": { + "node_modules/ldapauth-fork/node_modules/ldapjs": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", @@ -9330,6 +9400,35 @@ "node": ">=10.13.0" } }, + "node_modules/ldapauth-fork/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0f7b5a590..31b9fdb14 100644 --- a/backend/package.json +++ b/backend/package.json @@ -106,6 +106,7 @@ "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "knex": "^3.0.1", + "ldapjs": "^3.0.7", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 2c8b8be5a..8845c1d01 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -74,6 +74,9 @@ import { TLdapConfigs, TLdapConfigsInsert, TLdapConfigsUpdate, + TLdapGroupMaps, + TLdapGroupMapsInsert, + TLdapGroupMapsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -398,6 +401,7 @@ declare module "knex/types/tables" { >; [TableName.SamlConfig]: Knex.CompositeTableType; [TableName.LdapConfig]: Knex.CompositeTableType; + [TableName.LdapGroupMap]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; [TableName.AuditLog]: Knex.CompositeTableType; [TableName.GitAppInstallSession]: Knex.CompositeTableType< diff --git a/backend/src/db/migrations/20240423023203_ldap-config-groups.ts b/backend/src/db/migrations/20240423023203_ldap-config-groups.ts new file mode 100644 index 000000000..dd4da5123 --- /dev/null +++ b/backend/src/db/migrations/20240423023203_ldap-config-groups.ts @@ -0,0 +1,34 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.LdapGroupMap))) { + await knex.schema.createTable(TableName.LdapGroupMap, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("ldapConfigId").notNullable(); + t.foreign("ldapConfigId").references("id").inTable(TableName.LdapConfig).onDelete("CASCADE"); + t.string("ldapGroupCN").notNullable(); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.unique(["ldapGroupCN", "groupId", "ldapConfigId"]); + }); + } + + await createOnUpdateTrigger(knex, TableName.LdapGroupMap); + + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.string("groupSearchBase").notNullable().defaultTo(""); + t.string("groupSearchFilter").notNullable().defaultTo(""); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.LdapGroupMap); + await dropOnUpdateTrigger(knex, TableName.LdapGroupMap); + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.dropColumn("groupSearchBase"); + t.dropColumn("groupSearchFilter"); + }); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b9dab06ba..30d6208b8 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -22,6 +22,7 @@ export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; export * from "./ldap-configs"; +export * from "./ldap-group-maps"; export * from "./models"; export * from "./org-bots"; export * from "./org-memberships"; diff --git a/backend/src/db/schemas/ldap-configs.ts b/backend/src/db/schemas/ldap-configs.ts index e3c6c8c75..70394a65c 100644 --- a/backend/src/db/schemas/ldap-configs.ts +++ b/backend/src/db/schemas/ldap-configs.ts @@ -23,7 +23,9 @@ export const LdapConfigsSchema = z.object({ caCertIV: z.string(), caCertTag: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + groupSearchBase: z.string().default(""), + groupSearchFilter: z.string().default("") }); export type TLdapConfigs = z.infer; diff --git a/backend/src/db/schemas/ldap-group-maps.ts b/backend/src/db/schemas/ldap-group-maps.ts new file mode 100644 index 000000000..d51d151b8 --- /dev/null +++ b/backend/src/db/schemas/ldap-group-maps.ts @@ -0,0 +1,19 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const LdapGroupMapsSchema = z.object({ + id: z.string().uuid(), + ldapConfigId: z.string().uuid(), + ldapGroupCN: z.string(), + groupId: z.string().uuid() +}); + +export type TLdapGroupMaps = z.infer; +export type TLdapGroupMapsInsert = Omit, TImmutableDBKeys>; +export type TLdapGroupMapsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index d5cf1b886..ea70dccdb 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -60,6 +60,7 @@ export enum TableName { SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", LdapConfig = "ldap_configs", + LdapGroupMap = "ldap_group_maps", AuditLog = "audit_logs", GitAppInstallSession = "git_app_install_sessions", GitAppOrg = "git_app_org", diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index c35d275ae..09819dde8 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -14,7 +14,9 @@ import { FastifyRequest } from "fastify"; import LdapStrategy from "passport-ldapauth"; import { z } from "zod"; -import { LdapConfigsSchema } from "@app/db/schemas"; +import { LdapConfigsSchema, LdapGroupMapsSchema } from "@app/db/schemas"; +import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; +import { searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -50,20 +52,33 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line async (req: IncomingMessage, user, cb) => { try { + const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; + + const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; + const searchFilter = + ldapConfig.groupSearchFilter || + groupFilter.replace("{{.Username}}", user.uid).replace("{{.UserDN}}", user.dn); + + const shouldProcessGroups = ldapConfig.groupSearchFilter && ldapConfig.groupSearchBase; + const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ + ldapConfigId: ldapConfig.id, externalId: user.uidNumber, username: user.uid, - firstName: user.givenName, - lastName: user.sn, + firstName: user.givenName ?? user.cn ?? "", + lastName: user.sn ?? "", emails: user.mail ? [user.mail] : [], + groups: shouldProcessGroups + ? await searchGroups(ldapConfig, searchFilter, ldapConfig.groupSearchBase) + : undefined, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, orgId: (req as unknown as FastifyRequest).ldapConfig.organization }); return cb(null, { isUserCompleted, providerAuthToken }); - } catch (err) { - logger.error(err); - return cb(err, false); + } catch (error) { + logger.error(error); + return cb(error, false); } } ) @@ -117,6 +132,8 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { bindDN: z.string(), bindPass: z.string(), searchBase: z.string(), + groupSearchBase: z.string(), + groupSearchFilter: z.string(), caCert: z.string() }) } @@ -148,6 +165,8 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { bindDN: z.string().trim(), bindPass: z.string().trim(), searchBase: z.string().trim(), + groupSearchBase: z.string().trim().default(""), + groupSearchFilter: z.string().trim().default(""), caCert: z.string().trim().default("") }), response: { @@ -183,6 +202,8 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { bindDN: z.string().trim(), bindPass: z.string().trim(), searchBase: z.string().trim(), + groupSearchBase: z.string().trim(), + groupSearchFilter: z.string().trim(), caCert: z.string().trim() }) .partial() @@ -204,4 +225,106 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { return ldap; } }); + + server.route({ + method: "GET", + url: "/config/:configId/group-maps", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim() + }), + response: { + 200: z.array( + z.object({ + id: z.string(), + ldapConfigId: z.string(), + ldapGroupCN: z.string(), + group: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + }) + ) + } + }, + handler: async (req) => { + const ldapGroupMaps = await server.services.ldap.getLdapGroupMaps({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId + }); + return ldapGroupMaps; + } + }); + + server.route({ + method: "POST", + url: "/config/:configId/group-maps", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim() + }), + body: z.object({ + ldapGroupCN: z.string().trim(), + groupSlug: z.string().trim() + }), + response: { + 200: LdapGroupMapsSchema + } + }, + handler: async (req) => { + const ldapGroupMap = await server.services.ldap.createLdapGroupMap({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId, + ...req.body + }); + return ldapGroupMap; + } + }); + + server.route({ + method: "DELETE", + url: "/config/:configId/group-maps/:groupMapId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim(), + groupMapId: z.string().trim() + }), + response: { + 200: LdapGroupMapsSchema + } + }, + handler: async (req) => { + const ldapGroupMap = await server.services.ldap.deleteLdapGroupMap({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId, + ldapGroupMapId: req.params.groupMapId + }); + return ldapGroupMap; + } + }); }; diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index 8b37de300..e308891f9 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -22,10 +22,6 @@ const addAcceptedUsersToGroup = async ({ projectBotDAL, tx }: TAddUsersToGroup) => { - console.log("addAcceptedUsersToGroup args: ", { - userIds, - group - }); const users = await userDAL.findUserEncKeyByUserIdsBatch( { userIds diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 76e2d40ce..90c641109 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -2,6 +2,9 @@ import { ForbiddenError } from "@casl/ability"; import jwt from "jsonwebtoken"; import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, TLdapConfigsUpdate } from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric, @@ -13,8 +16,12 @@ import { } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-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 { TUserDALFactory } from "@app/services/user/user-dal"; import { normalizeUsername } from "@app/services/user/user-fns"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -23,16 +30,38 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TLdapConfigDALFactory } from "./ldap-config-dal"; -import { TCreateLdapCfgDTO, TGetLdapCfgDTO, TLdapLoginDTO, TUpdateLdapCfgDTO } from "./ldap-config-types"; +import { + TCreateLdapCfgDTO, + TCreateLdapGroupMapDTO, + TDeleteLdapGroupMapDTO, + TGetLdapCfgDTO, + TGetLdapGroupMapsDTO, + TLdapLoginDTO, + TUpdateLdapCfgDTO +} from "./ldap-config-types"; +import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { - ldapConfigDAL: TLdapConfigDALFactory; + ldapConfigDAL: Pick; + ldapGroupMapDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; orgBotDAL: Pick; - userDAL: Pick; + groupDAL: Pick; + groupProjectDAL: Pick; + projectKeyDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete" + >; + userDAL: Pick< + TUserDALFactory, + "create" | "findOne" | "transaction" | "updateById" | "findUserEncKeyByUserIdsBatch" | "find" + >; userAliasDAL: Pick; permissionService: Pick; licenseService: Pick; @@ -42,8 +71,15 @@ export type TLdapConfigServiceFactory = ReturnType { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); @@ -135,6 +173,8 @@ export const ldapConfigServiceFactory = ({ bindPassIV, bindPassTag, searchBase, + groupSearchBase, + groupSearchFilter, encryptedCACert, caCertIV, caCertTag @@ -154,6 +194,8 @@ export const ldapConfigServiceFactory = ({ bindDN, bindPass, searchBase, + groupSearchBase, + groupSearchFilter, caCert }: TUpdateLdapCfgDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); @@ -169,7 +211,9 @@ export const ldapConfigServiceFactory = ({ const updateQuery: TLdapConfigsUpdate = { isActive, url, - searchBase + searchBase, + groupSearchBase, + groupSearchFilter }; const orgBot = await orgBotDAL.findOne({ orgId }); @@ -271,6 +315,8 @@ export const ldapConfigServiceFactory = ({ bindDN, bindPass, searchBase: ldapConfig.searchBase, + groupSearchBase: ldapConfig.groupSearchBase, + groupSearchFilter: ldapConfig.groupSearchFilter, caCert }; }; @@ -305,7 +351,7 @@ export const ldapConfigServiceFactory = ({ bindCredentials: ldapConfig.bindPass, searchBase: ldapConfig.searchBase, searchFilter: "(uid={{username}})", - searchAttributes: ["uid", "uidNumber", "givenName", "sn", "mail"], + // searchAttributes: ["uid", "uidNumber", "givenName", "sn", "mail"], ...(ldapConfig.caCert !== "" ? { tlsOptions: { @@ -320,7 +366,17 @@ export const ldapConfigServiceFactory = ({ return { opts, ldapConfig }; }; - const ldapLogin = async ({ externalId, username, firstName, lastName, emails, orgId, relayState }: TLdapLoginDTO) => { + const ldapLogin = async ({ + ldapConfigId, + externalId, + username, + firstName, + lastName, + emails, + groups, + orgId, + relayState + }: TLdapLoginDTO) => { const appCfg = getConfig(); let userAlias = await userAliasDAL.findOne({ externalId, @@ -394,7 +450,84 @@ export const ldapConfigServiceFactory = ({ }); } - const user = await userDAL.findOne({ id: userAlias.userId }); + const user = await userDAL.transaction(async (tx) => { + const newUser = await userDAL.findOne({ id: userAlias.userId }, tx); + if (groups) { + const ldapGroupIdsToBePartOf = ( + await ldapGroupMapDAL.find({ + ldapConfigId, + $in: { + ldapGroupCN: groups.map((group) => group.cn) + } + }) + ).map((groupMap) => groupMap.groupId); + + const groupsToBePartOf = await groupDAL.find({ + orgId, + $in: { + id: ldapGroupIdsToBePartOf + } + }); + const toBePartOfGroupIdsSet = new Set(groupsToBePartOf.map((groupToBePartOf) => groupToBePartOf.id)); + + const allLdapGroupMaps = await ldapGroupMapDAL.find({ + ldapConfigId + }); + + const ldapGroupIdsCurrentlyPartOf = ( + await userGroupMembershipDAL.find({ + userId: newUser.id, + $in: { + groupId: allLdapGroupMaps.map((groupMap) => groupMap.groupId) + } + }) + ).map((userGroupMembership) => userGroupMembership.groupId); + + const userGroupMembershipGroupIdsSet = new Set(ldapGroupIdsCurrentlyPartOf); + + for await (const group of groupsToBePartOf) { + if (!userGroupMembershipGroupIdsSet.has(group.id)) { + // add user to group that they should be part of + await addUsersToGroupByUserIds({ + group, + userIds: [newUser.id], + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + } + } + + const groupsCurrentlyPartOf = await groupDAL.find({ + orgId, + $in: { + id: ldapGroupIdsCurrentlyPartOf + } + }); + + for await (const group of groupsCurrentlyPartOf) { + if (!toBePartOfGroupIdsSet.has(group.id)) { + // remove user from group that they should no longer be part of + await removeUsersFromGroupByUserIds({ + group, + userIds: [newUser.id], + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx + }); + } + } + } + + return newUser; + }); const isUserCompleted = Boolean(user.isAccepted); @@ -424,6 +557,99 @@ export const ldapConfigServiceFactory = ({ return { isUserCompleted, providerAuthToken }; }; + const getLdapGroupMaps = async ({ + ldapConfigId, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TGetLdapGroupMapsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); + + const ldapConfig = await ldapConfigDAL.findOne({ + id: ldapConfigId, + orgId + }); + + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const groupMaps = await ldapGroupMapDAL.findLdapGroupMapsByLdapConfigId(ldapConfigId); + + return groupMaps; + }; + + const createLdapGroupMap = async ({ + ldapConfigId, + ldapGroupCN, + groupSlug, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TCreateLdapGroupMapDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: "Failed to create LDAP group map due to plan restriction. Upgrade plan to create LDAP group map." + }); + + const ldapConfig = await ldapConfigDAL.findOne({ + id: ldapConfigId, + orgId + }); + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const group = await groupDAL.findOne({ slug: groupSlug, orgId }); + if (!group) throw new BadRequestError({ message: "Failed to find group" }); + + const groupMap = await ldapGroupMapDAL.create({ + ldapConfigId, + ldapGroupCN, + groupId: group.id + }); + + return groupMap; + }; + + const deleteLdapGroupMap = async ({ + ldapConfigId, + ldapGroupMapId, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TDeleteLdapGroupMapDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: "Failed to delete LDAP group map due to plan restriction. Upgrade plan to delete LDAP group map." + }); + + const ldapConfig = await ldapConfigDAL.findOne({ + id: ldapConfigId, + orgId + }); + + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const [deletedGroupMap] = await ldapGroupMapDAL.delete({ + ldapConfigId: ldapConfig.id, + id: ldapGroupMapId + }); + + return deletedGroupMap; + }; + return { createLdapCfg, updateLdapCfg, @@ -431,6 +657,9 @@ export const ldapConfigServiceFactory = ({ getLdapCfg, // getLdapPassportOpts, ldapLogin, - bootLdap + bootLdap, + getLdapGroupMaps, + createLdapGroupMap, + deleteLdapGroupMap }; }; diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index 4e261f9e9..03254b92f 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -1,5 +1,18 @@ import { TOrgPermission } from "@app/lib/types"; +export type TLDAPConfig = { + id: string; + organization: string; + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + groupSearchBase: string; + groupSearchFilter: string; + caCert: string; +}; + export type TCreateLdapCfgDTO = { orgId: string; isActive: boolean; @@ -7,6 +20,8 @@ export type TCreateLdapCfgDTO = { bindDN: string; bindPass: string; searchBase: string; + groupSearchBase: string; + groupSearchFilter: string; caCert: string; } & TOrgPermission; @@ -18,6 +33,8 @@ export type TUpdateLdapCfgDTO = { bindDN: string; bindPass: string; searchBase: string; + groupSearchBase: string; + groupSearchFilter: string; caCert: string; }> & TOrgPermission; @@ -27,11 +44,31 @@ export type TGetLdapCfgDTO = { } & TOrgPermission; export type TLdapLoginDTO = { + ldapConfigId: string; externalId: string; username: string; firstName: string; lastName: string; emails: string[]; orgId: string; + groups?: { + dn: string; + cn: string; + }[]; relayState?: string; }; + +export type TGetLdapGroupMapsDTO = { + ldapConfigId: string; +} & TOrgPermission; + +export type TCreateLdapGroupMapDTO = { + ldapConfigId: string; + ldapGroupCN: string; + groupSlug: string; +} & TOrgPermission; + +export type TDeleteLdapGroupMapDTO = { + ldapConfigId: string; + ldapGroupMapId: string; +} & TOrgPermission; diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts new file mode 100644 index 000000000..922fcb3f5 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -0,0 +1,72 @@ +import ldapjs from "ldapjs"; + +import { logger } from "@app/lib/logger"; + +import { TLDAPConfig } from "./ldap-config-types"; + +export const searchGroups = async ( + ldapConfig: TLDAPConfig, + filter: string, + base: string +): Promise<{ dn: string; cn: string }[]> => { + return new Promise((resolve, reject) => { + const ldapClient = ldapjs.createClient({ + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }); + + ldapClient.search( + base, + { + filter, + scope: "sub" + }, + (err, res) => { + if (err) { + ldapClient.unbind((unbindError) => { + if (unbindError) { + logger.error("Error unbinding LDAP client:", unbindError); + } + }); + return reject(err); + } + + const groups: { dn: string; cn: string }[] = []; + + res.on("searchEntry", (entry) => { + const dn = entry.dn.toString(); + const regex = /cn=([^,]+)/; + const match = dn.match(regex); + // parse the cn from the dn + const cn = (match && match[1]) as string; + + groups.push({ dn, cn }); + }); + res.on("error", (error) => { + ldapClient.unbind((unbindError) => { + if (unbindError) { + logger.error("Error unbinding LDAP client:", unbindError); + } + }); + reject(error); + }); + res.on("end", () => { + ldapClient.unbind((unbindError) => { + if (unbindError) { + logger.error("Error unbinding LDAP client:", unbindError); + } + }); + resolve(groups); + }); + } + ); + }); +}; diff --git a/backend/src/ee/services/ldap-config/ldap-group-map-dal.ts b/backend/src/ee/services/ldap-config/ldap-group-map-dal.ts new file mode 100644 index 000000000..2264efa75 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-group-map-dal.ts @@ -0,0 +1,41 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TLdapGroupMapDALFactory = ReturnType; + +export const ldapGroupMapDALFactory = (db: TDbClient) => { + const ldapGroupMapOrm = ormify(db, TableName.LdapGroupMap); + + const findLdapGroupMapsByLdapConfigId = async (ldapConfigId: string) => { + try { + const docs = await db(TableName.LdapGroupMap) + .where(`${TableName.LdapGroupMap}.ldapConfigId`, ldapConfigId) + .join(TableName.Groups, `${TableName.LdapGroupMap}.groupId`, `${TableName.Groups}.id`) + .select(selectAllTableCols(TableName.LdapGroupMap)) + .select( + db.ref("id").withSchema(TableName.Groups).as("groupId"), + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") + ); + + return docs.map((doc) => { + return { + id: doc.id, + ldapConfigId: doc.ldapConfigId, + ldapGroupCN: doc.ldapGroupCN, + group: { + id: doc.groupId, + name: doc.groupName, + slug: doc.groupSlug + } + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "findGroupMaps" }); + } + }; + + return { ...ldapGroupMapOrm, findLdapGroupMapsByLdapConfigId }; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5ed2589f6..4cb56a222 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -18,6 +18,7 @@ import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/i import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; +import { ldapGroupMapDALFactory } from "@app/ee/services/ldap-config/ldap-group-map-dal"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; @@ -200,6 +201,7 @@ export const registerRoutes = async ( const samlConfigDAL = samlConfigDALFactory(db); const scimDAL = scimDALFactory(db); const ldapConfigDAL = ldapConfigDALFactory(db); + const ldapGroupMapDAL = ldapGroupMapDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); @@ -300,8 +302,15 @@ export const registerRoutes = async ( const ldapService = ldapConfigServiceFactory({ ldapConfigDAL, + ldapGroupMapDAL, orgDAL, orgBotDAL, + groupDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + userGroupMembershipDAL, userDAL, userAliasDAL, permissionService, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index fa7439af8..5d81eaae1 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -191,7 +191,7 @@ export const authLoginServiceFactory = ({ const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); authMethod = decodedProviderToken.authMethod; - if (isAuthMethodSaml(authMethod) && decodedProviderToken.orgId) { + if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && decodedProviderToken.orgId) { organizationId = decodedProviderToken.orgId; } } diff --git a/docs/documentation/platform/ldap.mdx b/docs/documentation/platform/ldap.mdx deleted file mode 100644 index ba01aa743..000000000 --- a/docs/documentation/platform/ldap.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "LDAP" -description: "Log in to Infisical with LDAP" ---- - - - LDAP is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. - - -You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol). - - - - In Infisical, head to your Organization Settings > Authentication > LDAP Configuration and select **Set up LDAP**. - - Next, input your LDAP server settings. - - ![LDAP configuration](/images/platform/ldap/ldap-config.png) - - Here's some guidance for each field: - - - URL: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` (for connection over SSL/TLS), etc. - - Bind DN: The distinguished name of object to bind when performing the user search such as `cn=infisical,ou=Users,dc=acme,dc=com`. - - Bind Pass: The password to use along with `Bind DN` when performing the user search. - - Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=example,dc=com` - - CA Certificate: The CA certificate to use when verifying the LDAP server certificate. - - - Enabling LDAP allows members in your organization to log into Infisical via LDAP. - - ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) - - \ No newline at end of file diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index 5e50b736b..535cce734 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -4,16 +4,17 @@ description: "Learn how to log in to Infisical with LDAP." --- - LDAP is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + LDAP is a paid feature. If you're using Infisical Cloud, then it is available + under the **Enterprise Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) - In Infisical, head to your Organization Settings > Authentication > LDAP Configuration and select **Set up LDAP**. + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. Next, input your LDAP server settings. @@ -24,11 +25,41 @@ You can configure your organization in Infisical to have members authenticate wi - URL: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` (for connection over SSL/TLS), etc. - Bind DN: The distinguished name of object to bind when performing the user search such as `cn=infisical,ou=Users,dc=acme,dc=com`. - Bind Pass: The password to use along with `Bind DN` when performing the user search. - - Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=example,dc=com` + - Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com` + - Group Search Base / Group DN (optional): LDAP search base to use for group membership search such as `ou=Groups,dc=acme,dc=com`. + - Group Filter (optional): Template used when constructing the group membership query such as `(objectClass=posixGroup)`. The template can access the following context variables: [`UserDN`, `UserUID`, `UserName`]. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))` which is compatible with several common directory schemas. - CA Certificate: The CA certificate to use when verifying the LDAP server certificate. + + + The **Group Search Base / Group DN** and **Group Filter** fields are both required if you wish to sync LDAP groups to Infisical. + + + + + In order to sync LDAP groups to Infisical, head to the **LDAP Group Mappings** section to define mappings from LDAP groups to groups in Infisical. + + ![LDAP group mappings section](/images/platform/ldap/ldap-group-mappings-section.png) + + Group mappings ensure that users who log into Infisical via LDAP are added to or removed from the Infisical group(s) that corresponds to the LDAP group(s) they are a member of. + + ![LDAP group mappings table](/images/platform/ldap/ldap-group-mappings-table.png) + + Each group mapping consists of two parts: + - LDAP Group CN: The common name of the LDAP group to map. + - Infisical Group: The Infisical group to map the LDAP group to. + + For example, suppose you want to automatically add a user who is part of the LDAP group with CN `Engineers` to the Infisical group `Engineers` when the user sets up their account with Infisical. + + In this case, you would specify a mapping from the LDAP group with CN `Engineers` to the Infisical group `Engineers`. + Now when the user logs into Infisical via LDAP, Infisical will check the LDAP groups that the user is a part of whilst referencing the group mappings you created earlier. Since the user is a member of the LDAP group with CN `Engineers`, they will be added to the Infisical group `Engineers`. + In the future, if the user is no longer part of the LDAP group with CN `Engineers`, they will be removed from the Infisical group `Engineers` upon their next login. + + Prior to defining any group mappings, ensure that you've created the Infisical groups that you want to map the LDAP groups to. + You can read more about creating (user) groups in Infisical [here](/documentation/platform/groups). + Enabling LDAP allows members in your organization to log into Infisical via LDAP. ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) - \ No newline at end of file + diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 454a4d522..32b253eb7 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -4,9 +4,10 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi --- - LDAP is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + LDAP is a paid feature. If you're using Infisical Cloud, then it is available + under the **Enterprise Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. @@ -17,13 +18,13 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi When creating the user, input their **First Name**, **Last Name**, **Username** (required), **Company Email** (required), and **Description**. Also, create a password for the user. - Next, under User Security Settings and Permissions > Permission Settings, check the box next to **Enable as LDAP Bind DN**. + Next, under User Security Settings and Permissions > Permission Settings, check the box next to **Enable as LDAP Bind DN**. ![LDAP JumpCloud](/images/platform/ldap/jumpcloud/ldap-jumpcloud-enable-bind-dn.png) - In Infisical, head to your Organization Settings > Authentication > LDAP Configuration and select **Set up LDAP**. + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. Next, input your JumpCloud LDAP server settings. @@ -35,20 +36,48 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi - Bind DN: The distinguished name of object to bind when performing the user search (`uid=,ou=Users,o=,dc=jumpcloud,dc=com`). - Bind Pass: The password to use along with `Bind DN` when performing the user search. - Search Base / User DN: Base DN under which to perform user search (`ou=Users,o=,dc=jumpcloud,dc=com`). + - Group Search Base / Group DN (optional): LDAP search base to use for group membership search (`ou=Users,o=,dc=jumpcloud,dc=com`). + - Group Filter (optional): Template used when constructing the group membership query (`(objectClass=groupOfNames)`). - CA Certificate: The CA certificate to use when verifying the LDAP server certificate (instructions to obtain the certificate for JumpCloud [here](https://jumpcloud.com/support/connect-to-ldap-with-tls-ssl)). When filling out the **Bind DN** and **Bind Pass** fields, refer to the username and password of the user created in Step 1. - Also, for the **Bind DN** and **Search Base / User DN** fields, you'll want to use the organization ID that appears + Also, for the **Bind DN** and **Search Base / User DN** fields, you'll want to use the organization ID that appears in your LDAP instance **ORG DN**. + + In order to sync LDAP groups to Infisical, head to the **LDAP Group Mappings** section to define mappings from LDAP groups to groups in Infisical. + + ![LDAP group mappings section](/images/platform/ldap/ldap-group-mappings-section.png) + + Group mappings ensure that users who log into Infisical via LDAP are added to or removed from the Infisical group(s) that corresponds to the LDAP group(s) they are a member of. + + ![LDAP group mappings table](/images/platform/ldap/ldap-group-mappings-table.png) + + Each group mapping consists of two parts: + - LDAP Group CN: The common name of the LDAP group to map. + - Infisical Group: The Infisical group to map the LDAP group to. + + For example, suppose you want to automatically add a user who is part of the LDAP group with CN `Engineers` to the Infisical group `Engineers` when the user sets up their account with Infisical. + + In this case, you would specify a mapping from the LDAP group with CN `Engineers` to the Infisical group `Engineers`. + Now when the user logs into Infisical via LDAP, Infisical will check the LDAP groups that the user is a part of whilst referencing the group mappings you created earlier. Since the user is a member of the LDAP group with CN `Engineers`, they will be added to the Infisical group `Engineers`. + In the future, if the user is no longer part of the LDAP group with CN `Engineers`, they will be removed from the Infisical group `Engineers` upon their next login. + + Prior to defining any group mappings, ensure that you've created the Infisical groups that you want to map the LDAP groups to. + You can read more about creating (user) groups in Infisical [here](/documentation/platform/groups). + + + Enabling LDAP allows members in your organization to log into Infisical via LDAP. ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) + Resources: -- [JumpCloud Cloud LDAP Guide](https://jumpcloud.com/support/use-cloud-ldap) \ No newline at end of file + +- [JumpCloud Cloud LDAP Guide](https://jumpcloud.com/support/use-cloud-ldap) diff --git a/docs/images/platform/ldap/ldap-config.png b/docs/images/platform/ldap/ldap-config.png index 8d105c1d6..499b942b0 100644 Binary files a/docs/images/platform/ldap/ldap-config.png and b/docs/images/platform/ldap/ldap-config.png differ diff --git a/docs/images/platform/ldap/ldap-group-mappings-section.png b/docs/images/platform/ldap/ldap-group-mappings-section.png new file mode 100644 index 000000000..9f668e44b Binary files /dev/null and b/docs/images/platform/ldap/ldap-group-mappings-section.png differ diff --git a/docs/images/platform/ldap/ldap-group-mappings-table.png b/docs/images/platform/ldap/ldap-group-mappings-table.png new file mode 100644 index 000000000..1003b5af8 Binary files /dev/null and b/docs/images/platform/ldap/ldap-group-mappings-table.png differ diff --git a/docs/images/platform/ldap/ldap-toggle.png b/docs/images/platform/ldap/ldap-toggle.png index dcc7ffc96..30755b7ec 100644 Binary files a/docs/images/platform/ldap/ldap-toggle.png and b/docs/images/platform/ldap/ldap-toggle.png differ diff --git a/frontend/src/hooks/api/ldapConfig/index.tsx b/frontend/src/hooks/api/ldapConfig/index.tsx index bc93e7fcf..26af75089 100644 --- a/frontend/src/hooks/api/ldapConfig/index.tsx +++ b/frontend/src/hooks/api/ldapConfig/index.tsx @@ -1 +1,6 @@ -export { useCreateLDAPConfig, useGetLDAPConfig, useUpdateLDAPConfig } from "./queries"; +export { + useCreateLDAPConfig, + useCreateLDAPGroupMapping, + useDeleteLDAPGroupMapping, + useUpdateLDAPConfig} from "./mutations"; +export { useGetLDAPConfig, useGetLDAPGroupMaps } from "./queries"; diff --git a/frontend/src/hooks/api/ldapConfig/mutations.tsx b/frontend/src/hooks/api/ldapConfig/mutations.tsx new file mode 100644 index 000000000..50651b367 --- /dev/null +++ b/frontend/src/hooks/api/ldapConfig/mutations.tsx @@ -0,0 +1,138 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { ldapConfigKeys } from "./queries"; + +export const useCreateLDAPConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + groupSearchBase, + groupSearchFilter, + caCert + }: { + organizationId: string; + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + groupSearchBase: string; + groupSearchFilter: string; + caCert?: string; + }) => { + const { data } = await apiRequest.post("/api/v1/ldap/config", { + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + groupSearchBase, + groupSearchFilter, + caCert + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId)); + } + }); +}; + +export const useUpdateLDAPConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + groupSearchBase, + groupSearchFilter, + caCert + }: { + organizationId: string; + isActive?: boolean; + url?: string; + bindDN?: string; + bindPass?: string; + searchBase?: string; + groupSearchBase?: string; + groupSearchFilter?: string; + caCert?: string; + }) => { + const { data } = await apiRequest.patch("/api/v1/ldap/config", { + organizationId, + isActive, + url, + bindDN, + bindPass, + searchBase, + groupSearchBase, + groupSearchFilter, + caCert + }); + + return data; + }, + onSuccess(_, dto) { + queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId)); + } + }); +}; + +export const useCreateLDAPGroupMapping = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + ldapConfigId, + ldapGroupCN, + groupSlug + }: { + ldapConfigId: string; + ldapGroupCN: string; + groupSlug: string; + }) => { + const { data } = await apiRequest.post(`/api/v1/ldap/config/${ldapConfigId}/group-maps`, { + ldapGroupCN, + groupSlug + }); + return data; + }, + onSuccess(_, { ldapConfigId }) { + queryClient.invalidateQueries(ldapConfigKeys.getLDAPGroupMaps(ldapConfigId)); + } + }); +}; + +export const useDeleteLDAPGroupMapping = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + ldapConfigId, + ldapGroupMapId + }: { + ldapConfigId: string; + ldapGroupMapId: string; + }) => { + const { data } = await apiRequest.delete( + `/api/v1/ldap/config/${ldapConfigId}/group-maps/${ldapGroupMapId}` + ); + return data; + }, + onSuccess(_, { ldapConfigId }) { + queryClient.invalidateQueries(ldapConfigKeys.getLDAPGroupMaps(ldapConfigId)); + } + }); +}; diff --git a/frontend/src/hooks/api/ldapConfig/queries.tsx b/frontend/src/hooks/api/ldapConfig/queries.tsx index 4c97c5fc6..c84f90aac 100644 --- a/frontend/src/hooks/api/ldapConfig/queries.tsx +++ b/frontend/src/hooks/api/ldapConfig/queries.tsx @@ -1,9 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -const ldapConfigKeys = { - getLDAPConfig: (orgId: string) => [{ orgId }, "organization-ldap"] as const +import { LDAPGroupMap } from "./types"; + +export const ldapConfigKeys = { + getLDAPConfig: (orgId: string) => [{ orgId }, "organization-ldap"] as const, + getLDAPGroupMaps: (ldapConfigId: string) => [{ ldapConfigId }, "ldap-group-maps"] as const }; export const useGetLDAPConfig = (organizationId: string) => { @@ -18,78 +21,18 @@ export const useGetLDAPConfig = (organizationId: string) => { }); }; -export const useCreateLDAPConfig = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - organizationId, - isActive, - url, - bindDN, - bindPass, - searchBase, - caCert - }: { - organizationId: string; - isActive: boolean; - url: string; - bindDN: string; - bindPass: string; - searchBase: string; - caCert?: string; - }) => { - const { data } = await apiRequest.post("/api/v1/ldap/config", { - organizationId, - isActive, - url, - bindDN, - bindPass, - searchBase, - caCert - }); +export const useGetLDAPGroupMaps = (ldapConfigId: string) => { + return useQuery({ + queryKey: ldapConfigKeys.getLDAPGroupMaps(ldapConfigId), + queryFn: async () => { + if (!ldapConfigId) return []; + + const { data } = await apiRequest.get( + `/api/v1/ldap/config/${ldapConfigId}/group-maps` + ); return data; }, - onSuccess(_, dto) { - queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId)); - } - }); -}; - -export const useUpdateLDAPConfig = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - organizationId, - isActive, - url, - bindDN, - bindPass, - searchBase, - caCert - }: { - organizationId: string; - isActive?: boolean; - url?: string; - bindDN?: string; - bindPass?: string; - searchBase?: string; - caCert?: string; - }) => { - const { data } = await apiRequest.patch("/api/v1/ldap/config", { - organizationId, - isActive, - url, - bindDN, - bindPass, - searchBase, - caCert - }); - - return data; - }, - onSuccess(_, dto) { - queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId)); - } + enabled: true }); }; diff --git a/frontend/src/hooks/api/ldapConfig/types.ts b/frontend/src/hooks/api/ldapConfig/types.ts new file mode 100644 index 000000000..0d3519c14 --- /dev/null +++ b/frontend/src/hooks/api/ldapConfig/types.ts @@ -0,0 +1,10 @@ +export type LDAPGroupMap = { + id: string; + ldapConfigId: string; + ldapGroupCN: string; + group: { + id: string; + name: string; + slug: string; + }; +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPGroupMapModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPGroupMapModal.tsx new file mode 100644 index 000000000..2578dae4e --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPGroupMapModal.tsx @@ -0,0 +1,256 @@ +import { Controller, useForm } from "react-hook-form"; +import { faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useCreateLDAPGroupMapping, + useDeleteLDAPGroupMapping, + useGetLDAPConfig, + useGetLDAPGroupMaps, + useGetOrganizationGroups +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + ldapGroupCN: z.string().min(1, "LDAP Group CN is required"), + groupSlug: z.string().min(1, "Group Slug is required") +}); + +export type TFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["ldapGroupMap", "deleteLdapGroupMap"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteLdapGroupMap"]>, + data?: { + ldapGroupMapId: string; + ldapGroupCN: string; + } + ) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["ldapGroupMap", "deleteLdapGroupMap"]>, + state?: boolean + ) => void; +}; + +export const LDAPGroupMapModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + + const { data: ldapConfig } = useGetLDAPConfig(currentOrg?.id ?? ""); + const { data: groups } = useGetOrganizationGroups(currentOrg?.id ?? ""); + const { data: groupMaps, isLoading } = useGetLDAPGroupMaps(ldapConfig?.id ?? ""); + const { mutateAsync: createLDAPGroupMapping, isLoading: createIsLoading } = + useCreateLDAPGroupMapping(); + const { mutateAsync: deleteLDAPGroupMapping } = useDeleteLDAPGroupMapping(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + ldapGroupCN: "", + groupSlug: "" + } + }); + + const onFormSubmit = async ({ groupSlug, ldapGroupCN }: TFormData) => { + try { + if (!ldapConfig) return; + + await createLDAPGroupMapping({ + ldapConfigId: ldapConfig.id, + groupSlug, + ldapGroupCN + }); + + reset(); + + createNotification({ + text: `Successfully added LDAP group mapping for ${ldapGroupCN}`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to add LDAP group mapping for ${ldapGroupCN}`, + type: "error" + }); + } + }; + + const onDeleteGroupMapSubmit = async ({ + ldapConfigId, + ldapGroupMapId, + ldapGroupCN + }: { + ldapConfigId: string; + ldapGroupMapId: string; + ldapGroupCN: string; + }) => { + try { + await deleteLDAPGroupMapping({ + ldapConfigId, + ldapGroupMapId + }); + + handlePopUpToggle("deleteLdapGroupMap", false); + + createNotification({ + text: `Successfully deleted LDAP group mapping ${ldapGroupCN}`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete LDAP group mapping ${ldapGroupCN}`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("ldapGroupMap", isOpen); + reset(); + }} + > + +

New Group Mapping

+
+
+ ( + + + + )} + /> + ( + +
+ + +
+
+ )} + /> +
+
+

Group Mappings

+ + + + + + + + + + {isLoading && } + {!isLoading && + groupMaps?.map(({ id, ldapGroupCN, group }) => { + return ( + + + + + + ); + })} + +
LDAP Group CNInfisical Group +
{ldapGroupCN}{group.name} + { + handlePopUpOpen("deleteLdapGroupMap", { + ldapGroupMapId: id, + ldapGroupCN + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ {groupMaps?.length === 0 && ( + + )} +
+ handlePopUpToggle("deleteLdapGroupMap", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const deleteLdapGroupMapData = popUp?.deleteLdapGroupMap?.data as { + ldapGroupMapId: string; + ldapGroupCN: string; + }; + return onDeleteGroupMapSubmit({ + ldapConfigId: ldapConfig?.id ?? "", + ldapGroupMapId: deleteLdapGroupMapData.ldapGroupMapId, + ldapGroupCN: deleteLdapGroupMapData.ldapGroupCN + }); + }} + /> +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx index 3734d685d..773cba3f4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx @@ -14,6 +14,8 @@ const LDAPFormSchema = z.object({ bindDN: z.string().default(""), bindPass: z.string().default(""), searchBase: z.string().default(""), + groupSearchBase: z.string().default(""), + groupSearchFilter: z.string().default(""), caCert: z.string().optional() }); @@ -27,7 +29,7 @@ type Props = { export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { const { currentOrg } = useOrganization(); - + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig(); const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig(); const { data } = useGetLDAPConfig(currentOrg?.id ?? ""); @@ -43,12 +45,22 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN: data?.bindDN ?? "", bindPass: data?.bindPass ?? "", searchBase: data?.searchBase ?? "", + groupSearchBase: data?.groupSearchBase ?? "", + groupSearchFilter: data?.groupSearchFilter ?? "", caCert: data?.caCert ?? "" }); } }, [data]); - const onSSOModalSubmit = async ({ url, bindDN, bindPass, searchBase, caCert }: TLDAPFormData) => { + const onSSOModalSubmit = async ({ + url, + bindDN, + bindPass, + searchBase, + groupSearchBase, + groupSearchFilter, + caCert + }: TLDAPFormData) => { try { if (!currentOrg) return; @@ -60,6 +72,8 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN, bindPass, searchBase, + groupSearchBase, + groupSearchFilter, caCert }); } else { @@ -70,6 +84,8 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN, bindPass, searchBase, + groupSearchBase, + groupSearchFilter, caCert }); } @@ -139,6 +155,32 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) )} /> + ( + + + + )} + /> + ( + + + + )} + /> { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); - + const { data } = useGetLDAPConfig(currentOrg?.id ?? ""); + const { mutateAsync } = useUpdateLDAPConfig(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addLDAP", + "ldapGroupMap", + "deleteLdapGroupMap", "upgradePlan" ] as const); @@ -63,7 +67,9 @@ export const OrgLDAPSection = (): JSX.Element => { url: "", bindDN: "", bindPass: "", - searchBase: "" + searchBase: "", + groupSearchBase: "", + groupSearchFilter: "" }); } @@ -76,6 +82,15 @@ export const OrgLDAPSection = (): JSX.Element => { } }; + const openLDAPGroupMapModal = () => { + if (!subscription?.ldap) { + handlePopUpOpen("upgradePlan"); + return; + } + + handlePopUpOpen("ldapGroupMap"); + }; + return ( <>
@@ -92,6 +107,25 @@ export const OrgLDAPSection = (): JSX.Element => {

Manage LDAP authentication configuration

+
+
+

LDAP Group Mappings

+ + {(isAllowed) => ( + + )} + +
+

+ Manage how LDAP groups are mapped to internal groups in Infisical +

+
{data && (
@@ -119,6 +153,11 @@ export const OrgLDAPSection = (): JSX.Element => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> + handlePopUpToggle("upgradePlan", isOpen)}