diff --git a/backend/src/services/app-connection/ldap/ldap-connection-fns.ts b/backend/src/services/app-connection/ldap/ldap-connection-fns.ts index 03005c7d7..f7eb2189d 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-fns.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-fns.ts @@ -1,6 +1,10 @@ import ldap from "ldapjs"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; @@ -8,6 +12,22 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { LdapConnectionMethod } from "./ldap-connection-enums"; import { TLdapConnectionConfig } from "./ldap-connection-types"; +const parseLdapUrl = (url: string): { protocol: string; host: string; port: number } => { + const urlObj = new URL(url); + const isSSL = urlObj.protocol === "ldaps:"; + const defaultPort = isSSL ? 636 : 389; + + return { + protocol: urlObj.protocol.replace(":", ""), + host: urlObj.hostname, + port: urlObj.port ? parseInt(urlObj.port, 10) : defaultPort + }; +}; + +const constructLdapUrl = (protocol: string, host: string, port: number): string => { + return `${protocol}://${host}:${port}`; +}; + export const getLdapConnectionListItem = () => { return { name: "LDAP" as const, @@ -80,6 +100,183 @@ export const getLdapConnectionClient = async ({ }); }; +export const executeWithPotentialGateway = async ( + config: TLdapConnectionConfig, + gatewayService: Pick, + gatewayV2Service: Pick, + operation: (client: ldap.Client) => Promise +): Promise => { + const { gatewayId, credentials } = config; + const { protocol, host, port } = parseLdapUrl(credentials.url); + + if (gatewayId && gatewayService && gatewayV2Service) { + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost: host, + targetPort: port + }); + + if (platformConnectionDetails) { + return withGatewayV2Proxy( + async (proxyPort) => { + const proxyUrl = constructLdapUrl(protocol, "localhost", proxyPort); + const isSSL = protocol === "ldaps"; + const client = ldap.createClient({ + url: proxyUrl, + timeout: LDAP_TIMEOUT, + connectTimeout: LDAP_TIMEOUT, + tlsOptions: isSSL + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate ? [sslCertificate] : undefined + } + : undefined + }); + + return new Promise((resolve, reject) => { + client.on("error", (err: Error) => { + logger.error(err, "LDAP Error"); + client.destroy(); + reject(new Error(`Provider Error - ${err.message}`)); + }); + + client.on("connectError", (err: Error) => { + logger.error(err, "LDAP Connection Error"); + client.destroy(); + reject(new Error(`Provider Connect Error - ${err.message}`)); + }); + + client.on("connectRefused", (err: Error) => { + logger.error(err, "LDAP Connection Refused"); + client.destroy(); + reject(new Error(`Provider Connection Refused - ${err.message}`)); + }); + + client.on("connectTimeout", (err: Error) => { + logger.error(err, "LDAP Connection Timeout"); + client.destroy(); + reject(new Error(`Provider Connection Timeout - ${err.message}`)); + }); + + client.on("connect", () => { + client.bind(credentials.dn, credentials.password, async (err) => { + if (err) { + logger.error(err, "LDAP Bind Error"); + client.destroy(); + reject(new Error(`Bind Error: ${err.message}`)); + return; + } + + try { + const result = await operation(client); + resolve(result); + } catch (opError) { + reject(opError); + } finally { + client.destroy(); + } + }); + }); + }); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); + } + + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + return withGatewayProxy( + async (proxyPort) => { + const proxyUrl = constructLdapUrl(protocol, "localhost", proxyPort); + const isSSL = protocol === "ldaps"; + const client = ldap.createClient({ + url: proxyUrl, + timeout: LDAP_TIMEOUT, + connectTimeout: LDAP_TIMEOUT, + tlsOptions: isSSL + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate ? [sslCertificate] : undefined + } + : undefined + }); + return new Promise((resolve, reject) => { + client.on("error", (err: Error) => { + logger.error(err, "LDAP Error"); + client.destroy(); + reject(new Error(`Provider Error - ${err.message}`)); + }); + + client.on("connectError", (err: Error) => { + logger.error(err, "LDAP Connection Error"); + client.destroy(); + reject(new Error(`Provider Connect Error - ${err.message}`)); + }); + + client.on("connectRefused", (err: Error) => { + logger.error(err, "LDAP Connection Refused"); + client.destroy(); + reject(new Error(`Provider Connection Refused - ${err.message}`)); + }); + + client.on("connectTimeout", (err: Error) => { + logger.error(err, "LDAP Connection Timeout"); + client.destroy(); + reject(new Error(`Provider Connection Timeout - ${err.message}`)); + }); + + client.on("connect", () => { + client.bind(credentials.dn, credentials.password, async (err) => { + if (err) { + logger.error(err, "LDAP Bind Error"); + client.destroy(); + reject(new Error(`Bind Error: ${err.message}`)); + return; + } + + try { + const result = await operation(client); + resolve(result); + } catch (opError) { + reject(opError); + } finally { + client.destroy(); + } + }); + }); + }); + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost: host, + targetPort: port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + } + + // Non-gateway path - calls getLdapConnectionClient which has validation + const client = await getLdapConnectionClient(credentials); + try { + return await operation(client); + } finally { + client.destroy(); + } +}; + export const validateLdapConnectionCredentials = async ({ credentials }: TLdapConnectionConfig) => { let client: ldap.Client | undefined; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts index 134b9667b..7fc56d9d1 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -74,7 +74,9 @@ export const ValidateLdapConnectionCredentialsSchema = z.discriminatedUnion("met ]); export const CreateLdapConnectionSchema = ValidateLdapConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP) + GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP, { + supportsGateways: true + }) ); export const UpdateLdapConnectionSchema = z diff --git a/backend/src/services/app-connection/ldap/ldap-connection-types.ts b/backend/src/services/app-connection/ldap/ldap-connection-types.ts index ee69b2542..581fca560 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-types.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-types.ts @@ -17,6 +17,9 @@ export type TLdapConnectionInput = z.infer & export type TValidateLdapConnectionCredentialsSchema = typeof ValidateLdapConnectionCredentialsSchema; -export type TLdapConnectionConfig = DiscriminativePick & { +export type TLdapConnectionConfig = DiscriminativePick< + TLdapConnectionInput, + "method" | "app" | "credentials" | "gatewayId" +> & { orgId: string; }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx index 21e376c2e..d786a7d75 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx @@ -4,8 +4,10 @@ import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tab } from "@headlessui/react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { z } from "zod"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, @@ -18,8 +20,11 @@ import { TextArea, Tooltip } from "@app/components/v2"; +import { OrgPermissionSubjects, useSubscription } from "@app/context"; +import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { LdapConnectionMethod, LdapConnectionProvider, @@ -84,6 +89,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { defaultValues: appConnection ?? { app: AppConnection.LDAP, method: LdapConnectionMethod.SimpleBind, + gatewayId: null, credentials: { provider: LdapConnectionProvider.ActiveDirectory, url: "", @@ -104,6 +110,8 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { const selectedProvider = watch("credentials.provider"); const sslEnabled = watch("credentials.url")?.startsWith("ldaps://") ?? false; + const { subscription } = useSubscription(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); return ( @@ -114,6 +122,57 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + {subscription.gateway && ( + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+ )}