Merge pull request #4825 from Infisical/feature/ldap-gateway-support

feature: ldap gateway support
This commit is contained in:
Victor Hugo dos Santos
2025-11-17 10:34:52 -03:00
committed by GitHub
6 changed files with 265 additions and 99 deletions

View File

@@ -10,7 +10,7 @@ import {
import { logger } from "@app/lib/logger";
import { DistinguishedNameRegex } from "@app/lib/regex";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap";
import { executeWithPotentialGateway, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap";
import { generatePassword } from "../shared/utils";
import {
@@ -71,17 +71,18 @@ export const ldapPasswordRotationFactory: TRotationFactory<
TLdapPasswordRotationWithConnection,
TLdapPasswordRotationGeneratedCredentials,
TLdapPasswordRotationInput["temporaryParameters"]
> = (secretRotation, appConnectionDAL, kmsService) => {
> = (secretRotation, appConnectionDAL, kmsService, gatewayService, gatewayV2Service) => {
const { connection, parameters, secretsMapping, activeIndex } = secretRotation;
const { dn, passwordRequirements } = parameters;
const $verifyCredentials = async (credentials: Pick<TLdapConnection["credentials"], "dn" | "password">) => {
try {
const client = await getLdapConnectionClient({ ...connection.credentials, ...credentials });
client.unbind();
client.destroy();
await executeWithPotentialGateway(
{ ...connection, credentials: { ...connection.credentials, ...credentials } },
gatewayV2Service,
async () => {}
);
} catch (error) {
throw new Error(`Failed to verify credentials - ${(error as Error).message}`);
}
@@ -92,17 +93,7 @@ export const ldapPasswordRotationFactory: TRotationFactory<
if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection");
const client = await getLdapConnectionClient(
currentPassword
? {
...credentials,
password: currentPassword,
dn
}
: credentials
);
const isConnectionRotation = credentials.dn === dn;
const password = generatePassword(passwordRequirements);
let changes: ldap.Change[] | ldap.Change;
@@ -147,22 +138,32 @@ export const ldapPasswordRotationFactory: TRotationFactory<
throw new Error(`Unhandled provider: ${credentials.provider as LdapProvider}`);
}
try {
const userDn = await getDN(dn, client);
await new Promise((resolve, reject) => {
client.modify(userDn, changes, (err) => {
if (err) {
logger.error(err, "LDAP Password Rotation Failed");
reject(new Error(`Provider Modify Error: ${err.message}`));
} else {
resolve(true);
}
await executeWithPotentialGateway(
{
...connection,
credentials: currentPassword
? {
...credentials,
password: currentPassword,
dn
}
: credentials
},
gatewayV2Service,
async (client) => {
const userDn = await getDN(dn, client);
await new Promise<void>((resolve, reject) => {
client.modify(userDn, changes, (err) => {
if (err) {
logger.error(err, "LDAP Password Rotation Failed");
reject(new Error(`Provider Modify Error: ${err.message}`));
} else {
resolve();
}
});
});
});
} finally {
client.unbind();
client.destroy();
}
}
);
await $verifyCredentials({ dn, password });

View File

@@ -8,10 +8,10 @@ import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "../errors";
import { isPrivateIp } from "../ip/ipRange";
export const blockLocalAndPrivateIpAddresses = async (url: string) => {
export const blockLocalAndPrivateIpAddresses = async (url: string, isGateway = false) => {
const appCfg = getConfig();
if (appCfg.isDevelopmentMode) return;
if (appCfg.isDevelopmentMode || isGateway) return;
const validUrl = new URL(url);

View File

@@ -1,6 +1,11 @@
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 { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { GatewayProxyProtocol } 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 +13,66 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
import { LdapConnectionMethod } from "./ldap-connection-enums";
import { TLdapConnectionConfig } from "./ldap-connection-types";
const LDAP_TIMEOUT = 15_000;
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}`;
};
const setupLdapClientHandlers = <T>(
client: ldap.Client,
dn: string,
password: string,
onSuccess: (client: ldap.Client) => T | Promise<T>
): Promise<T> => {
return new Promise<T>((resolve, reject) => {
const handleError = (errorType: string, err: Error) => {
logger.error(err, errorType);
client.destroy();
reject(new Error(`${errorType.replace("LDAP ", "")} - ${err.message}`));
};
client.on("error", (err: Error) => handleError("LDAP Error", err));
client.on("connectError", (err: Error) => handleError("LDAP Connection Error", err));
client.on("connectRefused", (err: Error) => handleError("LDAP Connection Refused", err));
client.on("connectTimeout", (err: Error) => handleError("LDAP Connection Timeout", err));
client.on("connect", () => {
client.bind(dn, password, (err) => {
if (err) {
logger.error(err, "LDAP Bind Error");
client.destroy();
reject(new Error(`Bind Error: ${err.message}`));
return;
}
try {
const result = onSuccess(client);
if (result instanceof Promise) {
result.then((value) => resolve(value)).catch(reject);
} else {
resolve(result);
}
} catch (error) {
reject(error);
}
});
});
});
};
export const getLdapConnectionListItem = () => {
return {
name: "LDAP" as const,
@@ -16,8 +81,6 @@ export const getLdapConnectionListItem = () => {
};
};
const LDAP_TIMEOUT = 15_000;
export const getLdapConnectionClient = async ({
url,
dn,
@@ -25,78 +88,112 @@ export const getLdapConnectionClient = async ({
sslCertificate,
sslRejectUnauthorized = true
}: TLdapConnectionConfig["credentials"]) => {
await blockLocalAndPrivateIpAddresses(url);
await blockLocalAndPrivateIpAddresses(url, false);
const isSSL = url.startsWith("ldaps");
return new Promise<ldap.Client>((resolve, reject) => {
const client = ldap.createClient({
url,
timeout: LDAP_TIMEOUT,
connectTimeout: LDAP_TIMEOUT,
tlsOptions: isSSL
? {
rejectUnauthorized: sslRejectUnauthorized,
ca: sslCertificate ? [sslCertificate] : undefined
}
: undefined
});
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(dn, password, (err) => {
if (err) {
logger.error(err, "LDAP Bind Error");
reject(new Error(`Bind Error: ${err.message}`));
client.destroy();
const client = ldap.createClient({
url,
timeout: LDAP_TIMEOUT,
connectTimeout: LDAP_TIMEOUT,
tlsOptions: isSSL
? {
rejectUnauthorized: sslRejectUnauthorized,
ca: sslCertificate ? [sslCertificate] : undefined
}
resolve(client);
});
});
: undefined
});
return setupLdapClientHandlers<ldap.Client>(client, dn, password, (ldapClient) => ldapClient);
};
export const validateLdapConnectionCredentials = async ({ credentials }: TLdapConnectionConfig) => {
let client: ldap.Client | undefined;
export const executeWithPotentialGateway = async <T>(
config: TLdapConnectionConfig,
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
operation: (client: ldap.Client) => Promise<T>
): Promise<T> => {
const { gatewayId, credentials } = config;
const { protocol, host, port } = parseLdapUrl(credentials.url);
const appCfg = getConfig();
try {
client = await getLdapConnectionClient(credentials);
if (gatewayId && gatewayV2Service) {
await blockLocalAndPrivateIpAddresses(credentials.url, true);
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
gatewayId,
targetHost: host,
targetPort: port
});
// this shouldn't occur as handle connection error events in client but here as fallback
if (!client.connected) {
throw new BadRequestError({ message: "Unable to connect to LDAP server" });
if (!platformConnectionDetails) {
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
}
return credentials;
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection: ${(e as Error).message || "verify credentials"}`
});
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: config.credentials.sslRejectUnauthorized,
ca: config.credentials.sslCertificate ? [config.credentials.sslCertificate] : undefined,
servername: host,
// bypass hostname verification for development
...(appCfg.isDevelopmentMode ? { checkServerIdentity: () => undefined } : {})
}
: undefined
});
return setupLdapClientHandlers<T>(client, credentials.dn, credentials.password, async (ldapClient) => {
try {
return await operation(ldapClient);
} finally {
ldapClient.destroy();
}
});
},
{
protocol: GatewayProxyProtocol.Tcp,
relayHost: platformConnectionDetails.relayHost,
gateway: platformConnectionDetails.gateway,
relay: platformConnectionDetails.relay
}
);
}
// Non-gateway path - calls getLdapConnectionClient which has validation
const client = await getLdapConnectionClient(credentials);
try {
return await operation(client);
} finally {
client?.destroy();
client.destroy();
}
};
export const validateLdapConnectionCredentials = async (
config: TLdapConnectionConfig,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
) => {
try {
await executeWithPotentialGateway(config, gatewayV2Service, async (client) => {
// this shouldn't occur as handle connection error events in client but here as fallback
if (!client.connected) {
throw new BadRequestError({ message: "Unable to connect to LDAP server" });
}
});
return config.credentials;
} catch (error) {
throw new BadRequestError({
message: `Unable to validate connection: ${
(error as Error)?.message?.replaceAll(config.credentials.password, "********************") ??
"verify credentials"
}`
});
}
};

View File

@@ -75,7 +75,9 @@ export const ValidateLdapConnectionCredentialsSchema = z.discriminatedUnion("met
]);
export const CreateLdapConnectionSchema = ValidateLdapConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP)
GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP, {
supportsGateways: true
})
);
export const UpdateLdapConnectionSchema = z
@@ -84,7 +86,11 @@ export const UpdateLdapConnectionSchema = z
AppConnections.UPDATE(AppConnection.LDAP).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LDAP));
.and(
GenericUpdateAppConnectionFieldsSchema(AppConnection.LDAP, {
supportsGateways: true
})
);
export const LdapConnectionListItemSchema = z
.object({

View File

@@ -17,6 +17,9 @@ export type TLdapConnectionInput = z.infer<typeof CreateLdapConnectionSchema> &
export type TValidateLdapConnectionCredentialsSchema = typeof ValidateLdapConnectionCredentialsSchema;
export type TLdapConnectionConfig = DiscriminativePick<TLdapConnection, "method" | "app" | "credentials"> & {
export type TLdapConnectionConfig = DiscriminativePick<
TLdapConnectionInput,
"method" | "app" | "credentials" | "gatewayId"
> & {
orgId: string;
};

View File

@@ -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 (
<FormProvider {...form}>
@@ -114,6 +122,57 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
}}
>
{!isUpdate && <GenericAppConnectionsFields />}
{subscription.gateway && (
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value as string}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
)}
<div className="grid grid-cols-2 items-center gap-2">
<Controller
name="method"