diff --git a/backend/src/db/migrations/20240424235842_user-search-filter.ts b/backend/src/db/migrations/20240424235842_user-search-filter.ts new file mode 100644 index 000000000..c078acf84 --- /dev/null +++ b/backend/src/db/migrations/20240424235842_user-search-filter.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.string("searchFilter").notNullable().defaultTo(""); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.dropColumn("searchFilter"); + }); +} diff --git a/backend/src/db/schemas/ldap-configs.ts b/backend/src/db/schemas/ldap-configs.ts index 70394a65c..86fd6acb6 100644 --- a/backend/src/db/schemas/ldap-configs.ts +++ b/backend/src/db/schemas/ldap-configs.ts @@ -25,7 +25,8 @@ export const LdapConfigsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), groupSearchBase: z.string().default(""), - groupSearchFilter: z.string().default("") + groupSearchFilter: z.string().default(""), + searchFilter: z.string().default("") }); export type TLdapConfigs = z.infer; diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index c94538425..34db0fb7e 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -16,7 +16,7 @@ import { z } from "zod"; 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 { isValidLdapFilter, 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"; @@ -55,10 +55,14 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; - const searchFilter = (ldapConfig.groupSearchFilter || groupFilter) + const groupSearchFilter = (ldapConfig.groupSearchFilter || groupFilter) .replace(/{{\.Username}}/g, user.uid) .replace(/{{\.UserDN}}/g, user.dn); + if (!isValidLdapFilter(groupSearchFilter)) { + throw new Error("Generated LDAP search filter is invalid."); + } + const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ ldapConfigId: ldapConfig.id, externalId: user.uidNumber, @@ -67,7 +71,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { lastName: user.sn ?? "", emails: user.mail ? [user.mail] : [], groups: ldapConfig.groupSearchBase - ? await searchGroups(ldapConfig, searchFilter, ldapConfig.groupSearchBase) + ? await searchGroups(ldapConfig, groupSearchFilter, ldapConfig.groupSearchBase) : undefined, relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, orgId: (req as unknown as FastifyRequest).ldapConfig.organization @@ -130,6 +134,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { bindDN: z.string(), bindPass: z.string(), searchBase: z.string(), + searchFilter: z.string(), groupSearchBase: z.string(), groupSearchFilter: z.string(), caCert: z.string() @@ -163,8 +168,12 @@ 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(""), + searchFilter: z.string().trim().default("(uid={{username}})"), + groupSearchBase: z.string().trim(), + groupSearchFilter: z + .string() + .trim() + .default("(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"), caCert: z.string().trim().default("") }), response: { @@ -200,6 +209,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { bindDN: z.string().trim(), bindPass: z.string().trim(), searchBase: z.string().trim(), + searchFilter: z.string().trim(), groupSearchBase: z.string().trim(), groupSearchFilter: z.string().trim(), caCert: z.string().trim() 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 d175fc5e8..6141044da 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -40,7 +40,7 @@ import { TTestLdapConnectionDTO, TUpdateLdapCfgDTO } from "./ldap-config-types"; -import { testLDAPConfig } from "./ldap-fns"; +import { isValidLdapFilter, testLDAPConfig } from "./ldap-fns"; import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { @@ -98,6 +98,7 @@ export const ldapConfigServiceFactory = ({ bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -112,6 +113,18 @@ export const ldapConfigServiceFactory = ({ "Failed to create LDAP configuration due to plan restriction. Upgrade plan to create LDAP configuration." }); + const isSearchFilterValid = isValidLdapFilter(searchFilter); + if (!isSearchFilterValid) + throw new BadRequestError({ + message: "Failed to create LDAP configuration due to invalid search filter." + }); + + const isGroupSearchFilterValid = isValidLdapFilter(groupSearchFilter); + if (!isGroupSearchFilterValid) + throw new BadRequestError({ + message: "Failed to create LDAP configuration due to invalid group search filter." + }); + const orgBot = await orgBotDAL.transaction(async (tx) => { const doc = await orgBotDAL.findOne({ orgId }, tx); if (doc) return doc; @@ -175,6 +188,7 @@ export const ldapConfigServiceFactory = ({ bindPassIV, bindPassTag, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, encryptedCACert, @@ -196,6 +210,7 @@ export const ldapConfigServiceFactory = ({ bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -210,10 +225,27 @@ export const ldapConfigServiceFactory = ({ "Failed to update LDAP configuration due to plan restriction. Upgrade plan to update LDAP configuration." }); + if (searchFilter) { + const isSearchFilterValid = isValidLdapFilter(searchFilter); + if (!isSearchFilterValid) + throw new BadRequestError({ + message: "Failed to update LDAP configuration due to invalid search filter." + }); + } + + if (groupSearchFilter) { + const isGroupSearchFilterValid = isValidLdapFilter(groupSearchFilter); + if (!isGroupSearchFilterValid) + throw new BadRequestError({ + message: "Failed to update LDAP configuration due to invalid group search filter." + }); + } + const updateQuery: TLdapConfigsUpdate = { isActive, url, searchBase, + searchFilter, groupSearchBase, groupSearchFilter }; @@ -317,6 +349,7 @@ export const ldapConfigServiceFactory = ({ bindDN, bindPass, searchBase: ldapConfig.searchBase, + searchFilter: ldapConfig.searchFilter, groupSearchBase: ldapConfig.groupSearchBase, groupSearchFilter: ldapConfig.groupSearchFilter, caCert @@ -352,7 +385,7 @@ export const ldapConfigServiceFactory = ({ bindDN: ldapConfig.bindDN, bindCredentials: ldapConfig.bindPass, searchBase: ldapConfig.searchBase, - searchFilter: "(uid={{username}})", + searchFilter: ldapConfig.searchFilter || "(uid={{username}})", // searchAttributes: ["uid", "uidNumber", "givenName", "sn", "mail"], ...(ldapConfig.caCert !== "" ? { 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 1018ec5fa..b7e9feb7b 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -20,6 +20,7 @@ export type TCreateLdapCfgDTO = { bindDN: string; bindPass: string; searchBase: string; + searchFilter: string; groupSearchBase: string; groupSearchFilter: string; caCert: string; @@ -33,6 +34,7 @@ export type TUpdateLdapCfgDTO = { bindDN: string; bindPass: string; searchBase: string; + searchFilter: string; groupSearchBase: string; groupSearchFilter: string; caCert: string; diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index accd79e29..66d799583 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -4,6 +4,17 @@ import { logger } from "@app/lib/logger"; import { TLDAPConfig } from "./ldap-config-types"; +export const isValidLdapFilter = (filter: string) => { + try { + ldapjs.parseFilter(filter); + return true; + } catch (error) { + logger.error("Invalid LDAP filter"); + logger.error(error); + return false; + } +}; + /** * Test the LDAP configuration by attempting to bind to the LDAP server * @param ldapConfig - The LDAP configuration to test diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx index e13749910..aa4841625 100644 --- a/docs/documentation/platform/ldap/general.mdx +++ b/docs/documentation/platform/ldap/general.mdx @@ -25,7 +25,8 @@ 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=acme,dc=com` + - User Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`. + - User Search Filter (optional): Template used to construct the LDAP user search filter such as `(uid={{username}})`; use literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas. - 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)(memberUid={{.Username}}))`. The template can access the following context variables: [`UserDN`, `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. @@ -35,6 +36,14 @@ You can configure your organization in Infisical to have members authenticate wi + + Once you've filled out the LDAP configuration, you can test that part of the configuration is correct by pressing the **Test Connection** button. + + Infisical will attempt to bind to the LDAP server using the provided **URL**, **Bind DN**, and **Bind Pass**. If the operation is successful, then Infisical will display a success message; if not, then Infisical will display an error message and provide a fuller error in the server logs. + + ![LDAP test connection](/images/platform/ldap/ldap-test-connection.png) + + 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. diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx index 16b924617..0b40d8b3a 100644 --- a/docs/documentation/platform/ldap/jumpcloud.mdx +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -21,7 +21,6 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi 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 > Security > LDAP and select **Manage**. @@ -35,7 +34,8 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi - URL: The LDAP server to connect to (`ldaps://ldap.jumpcloud.com:636`). - 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`). + - User Search Base / User DN: Base DN under which to perform user search (`ou=Users,o=,dc=jumpcloud,dc=com`). + - User Search Filter (optional): Template used to construct the LDAP user search filter (`(uid={{username}})`). - 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)(member=uid={{.Username}},ou=Users,o=,dc=jumpcloud,dc=com))`) - 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)). @@ -47,6 +47,13 @@ description: "Learn how to configure JumpCloud LDAP for authenticating into Infi in your LDAP instance **ORG DN**. + + Once you've filled out the LDAP configuration, you can test that part of the configuration is correct by pressing the **Test Connection** button. + + Infisical will attempt to bind to the LDAP server using the provided **URL**, **Bind DN**, and **Bind Pass**. If the operation is successful, then Infisical will display a success message; if not, then Infisical will display an error message and provide a fuller error in the server logs. + + ![LDAP test connection](/images/platform/ldap/ldap-test-connection.png) + 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. diff --git a/docs/images/platform/ldap/ldap-config.png b/docs/images/platform/ldap/ldap-config.png index 499b942b0..2cd711dd1 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-test-connection.png b/docs/images/platform/ldap/ldap-test-connection.png new file mode 100644 index 000000000..9f1a3896c Binary files /dev/null and b/docs/images/platform/ldap/ldap-test-connection.png differ diff --git a/frontend/src/hooks/api/ldapConfig/mutations.tsx b/frontend/src/hooks/api/ldapConfig/mutations.tsx index d80c51c55..a93286cdb 100644 --- a/frontend/src/hooks/api/ldapConfig/mutations.tsx +++ b/frontend/src/hooks/api/ldapConfig/mutations.tsx @@ -14,6 +14,7 @@ export const useCreateLDAPConfig = () => { bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -24,6 +25,7 @@ export const useCreateLDAPConfig = () => { bindDN: string; bindPass: string; searchBase: string; + searchFilter: string; groupSearchBase: string; groupSearchFilter: string; caCert?: string; @@ -35,6 +37,7 @@ export const useCreateLDAPConfig = () => { bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -58,6 +61,7 @@ export const useUpdateLDAPConfig = () => { bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -68,6 +72,7 @@ export const useUpdateLDAPConfig = () => { bindDN?: string; bindPass?: string; searchBase?: string; + searchFilter?: string; groupSearchBase?: string; groupSearchFilter?: string; caCert?: string; @@ -79,6 +84,7 @@ export const useUpdateLDAPConfig = () => { bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx index aa729cf88..8a8a9e467 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/LDAPModal.tsx @@ -10,7 +10,8 @@ import { useCreateLDAPConfig, useGetLDAPConfig, useTestLDAPConnection, - useUpdateLDAPConfig} from "@app/hooks/api"; + useUpdateLDAPConfig +} from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const LDAPFormSchema = z.object({ @@ -18,6 +19,7 @@ const LDAPFormSchema = z.object({ bindDN: z.string().default(""), bindPass: z.string().default(""), searchBase: z.string().default(""), + searchFilter: z.string().default(""), groupSearchBase: z.string().default(""), groupSearchFilter: z.string().default(""), caCert: z.string().optional() @@ -47,6 +49,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) const watchBindDN = watch("bindDN"); const watchBindPass = watch("bindPass"); const watchSearchBase = watch("searchBase"); + const watchSearchFilter = watch("searchFilter"); const watchGroupSearchBase = watch("groupSearchBase"); const watchGroupSearchFilter = watch("groupSearchFilter"); const watchCaCert = watch("caCert"); @@ -58,6 +61,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN: data?.bindDN ?? "", bindPass: data?.bindPass ?? "", searchBase: data?.searchBase ?? "", + searchFilter: data?.searchFilter ?? "", groupSearchBase: data?.groupSearchBase ?? "", groupSearchFilter: data?.groupSearchFilter ?? "", caCert: data?.caCert ?? "" @@ -70,6 +74,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert, @@ -86,6 +91,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -98,6 +104,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN, bindPass, searchBase, + searchFilter, groupSearchBase, groupSearchFilter, caCert @@ -128,6 +135,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) bindDN: watchBindDN, bindPass: watchBindPass, searchBase: watchSearchBase, + searchFilter: watchSearchFilter, groupSearchBase: watchGroupSearchBase, groupSearchFilter: watchGroupSearchFilter, caCert: watchCaCert, @@ -201,7 +209,7 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) name="searchBase" render={({ field, fieldState: { error } }) => ( @@ -209,6 +217,19 @@ export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) )} /> + ( + + + + )} + /> - + )} />