feat(app-connection): HC Vault Gateway Support

This commit is contained in:
x032205
2025-09-01 22:40:41 -04:00
parent ab91863c77
commit 4f1cd69bcc
8 changed files with 212 additions and 47 deletions

View File

@@ -37,7 +37,7 @@
"build": "tsup --sourcemap", "build": "tsup --sourcemap",
"build:frontend": "npm run build --prefix ../frontend", "build:frontend": "npm run build --prefix ../frontend",
"start": "node --enable-source-maps dist/main.mjs", "start": "node --enable-source-maps dist/main.mjs",
"type:check": "tsc --noEmit", "type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit",
"lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src", "lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src",
"lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'",
"test:unit": "vitest run -c vitest.unit.config.ts", "test:unit": "vitest run -c vitest.unit.config.ts",

View File

@@ -600,7 +600,7 @@ export const appConnectionServiceFactory = ({
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
hcvault: hcVaultConnectionService(connectAppConnectionById), hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService),
windmill: windmillConnectionService(connectAppConnectionById), windmill: windmillConnectionService(connectAppConnectionById),
teamcity: teamcityConnectionService(connectAppConnectionById), teamcity: teamcityConnectionService(connectAppConnectionById),
oci: ociConnectionService(connectAppConnectionById, licenseService), oci: ociConnectionService(connectAppConnectionById, licenseService),

View File

@@ -1,18 +1,18 @@
import { AxiosError } from "axios"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import https from "https";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
import { import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
THCVaultConnection,
THCVaultConnectionConfig,
THCVaultMountResponse,
TValidateHCVaultConnectionCredentials
} from "./hc-vault-connection-types";
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
@@ -37,7 +37,77 @@ type TokenRespData = {
}; };
}; };
export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => { export const requestWithHCVaultGateway = async <T>(
appConnection: { gatewayId?: string | null },
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
requestConfig: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
const { gatewayId } = appConnection;
// If gateway isn't set up, don't proxy request
if (!gatewayId) {
return request.request(requestConfig);
}
const url = new URL(requestConfig.url as string);
await blockLocalAndPrivateIpAddresses(url.toString());
const [targetHost] = await verifyHostInputValidity(url.hostname, true);
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
return withGatewayProxy(
async (proxyPort) => {
const httpsAgent = new https.Agent({
servername: targetHost
});
url.protocol = "https:";
url.host = `localhost:${proxyPort}`;
const finalRequestConfig: AxiosRequestConfig = {
...requestConfig,
url: url.toString(),
httpsAgent,
headers: {
...requestConfig.headers,
Host: targetHost
}
};
try {
return await request.request(finalRequestConfig);
} catch (error) {
const axiosError = error as AxiosError;
logger.error(
{ message: axiosError.message, data: axiosError.response?.data },
"Error during HashiCorp Vault gateway request:"
);
throw error;
}
},
{
protocol: GatewayProxyProtocol.Tcp,
targetHost,
targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
};
export const getHCVaultAccessToken = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
// Return access token directly if not using AppRole method // Return access token directly if not using AppRole method
if (connection.method !== HCVaultConnectionMethod.AppRole) { if (connection.method !== HCVaultConnectionMethod.AppRole) {
return connection.credentials.accessToken; return connection.credentials.accessToken;
@@ -46,16 +116,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
// Generate temporary token for AppRole method // Generate temporary token for AppRole method
try { try {
const { instanceUrl, roleId, secretId } = connection.credentials; const { instanceUrl, roleId, secretId } = connection.credentials;
const tokenResp = await request.post<TokenRespData>(
`${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, const tokenResp = await requestWithHCVaultGateway<TokenRespData>(connection, gatewayService, {
{ role_id: roleId, secret_id: secretId }, url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
{ method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
} },
} data: { role_id: roleId, secret_id: secretId }
); });
if (tokenResp.status !== 200) { if (tokenResp.status !== 200) {
throw new BadRequestError({ throw new BadRequestError({
@@ -71,38 +141,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
} }
}; };
export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => { export const validateHCVaultConnectionCredentials = async (
const instanceUrl = await getHCVaultInstanceUrl(config); connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
try { try {
const accessToken = await getHCVaultAccessToken(config); const accessToken = await getHCVaultAccessToken(connection, gatewayService);
// Verify token // Verify token
await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, { await requestWithHCVaultGateway(connection, gatewayService, {
url: `${instanceUrl}/v1/auth/token/lookup-self`,
method: "GET",
headers: { "X-Vault-Token": accessToken } headers: { "X-Vault-Token": accessToken }
}); });
return config.credentials; return connection.credentials;
} catch (error: unknown) { } catch (error: unknown) {
logger.error(error, "Unable to verify HC Vault connection");
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
throw new BadRequestError({ throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}` message: `Failed to validate credentials: ${error.message || "Unknown error"}`
}); });
} }
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({ throw new BadRequestError({
message: "Unable to validate connection: verify credentials" message: `Unable to validate connection: verify credentials`
}); });
} }
}; };
export const listHCVaultMounts = async (appConnection: THCVaultConnection) => { export const listHCVaultMounts = async (
const instanceUrl = await getHCVaultInstanceUrl(appConnection); connection: THCVaultConnection,
const accessToken = await getHCVaultAccessToken(appConnection); gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const { data } = await request.get<THCVaultMountResponse>(`${instanceUrl}/v1/sys/mounts`, { const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/mounts`,
method: "GET",
headers: { headers: {
"X-Vault-Token": accessToken, "X-Vault-Token": accessToken,
...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {}) ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
} }
}); });

View File

@@ -88,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion("
]); ]);
export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and( export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault) GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })
); );
export const UpdateHCVaultConnectionSchema = z export const UpdateHCVaultConnectionSchema = z
@@ -98,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z
.optional() .optional()
.describe(AppConnections.UPDATE(AppConnection.HCVault).credentials) .describe(AppConnections.UPDATE(AppConnection.HCVault).credentials)
}) })
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault)); .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }));
export const HCVaultConnectionListItemSchema = z.object({ export const HCVaultConnectionListItemSchema = z.object({
name: z.literal("HCVault"), name: z.literal("HCVault"),

View File

@@ -1,3 +1,4 @@
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
@@ -11,12 +12,15 @@ type TGetAppConnectionFunc = (
actor: OrgServiceActor actor: OrgServiceActor
) => Promise<THCVaultConnection>; ) => Promise<THCVaultConnection>;
export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => { export const hcVaultConnectionService = (
getAppConnection: TGetAppConnectionFunc,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const listMounts = async (connectionId: string, actor: OrgServiceActor) => { const listMounts = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor); const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor);
try { try {
const mounts = await listHCVaultMounts(appConnection); const mounts = await listHCVaultMounts(appConnection, gatewayService);
return mounts; return mounts;
} catch (error) { } catch (error) {
logger.error(error, "Failed to establish connection with Hashicorp Vault"); logger.error(error, "Failed to establish connection with Hashicorp Vault");

View File

@@ -1,5 +1,6 @@
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
@@ -65,7 +66,11 @@ const updateHCVaultVariables = async ({
}; };
export const HCVaultSyncFns = { export const HCVaultSyncFns = {
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { syncSecrets: async (
secretSync: THCVaultSyncWithCredentials,
secretMap: TSecretMap,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const { const {
connection, connection,
environment, environment,
@@ -74,7 +79,7 @@ export const HCVaultSyncFns = {
} = secretSync; } = secretSync;
const { namespace } = connection.credentials; const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection); const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({ const variables = await listHCVaultVariables({
@@ -117,14 +122,18 @@ export const HCVaultSyncFns = {
}); });
} }
}, },
removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { removeSecrets: async (
secretSync: THCVaultSyncWithCredentials,
secretMap: TSecretMap,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const { const {
connection, connection,
destinationConfig: { mount, path } destinationConfig: { mount, path }
} = secretSync; } = secretSync;
const { namespace } = connection.credentials; const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection); const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path }); const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path });
@@ -143,14 +152,17 @@ export const HCVaultSyncFns = {
}); });
} }
}, },
getSecrets: async (secretSync: THCVaultSyncWithCredentials) => { getSecrets: async (
secretSync: THCVaultSyncWithCredentials,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const { const {
connection, connection,
destinationConfig: { mount, path } destinationConfig: { mount, path }
} = secretSync; } = secretSync;
const { namespace } = connection.credentials; const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection); const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({ const variables = await listHCVaultVariables({

View File

@@ -244,7 +244,7 @@ export const SecretSyncFns = {
case SecretSync.Windmill: case SecretSync.Windmill:
return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap); return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.HCVault: case SecretSync.HCVault:
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService);
case SecretSync.TeamCity: case SecretSync.TeamCity:
return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.OCIVault: case SecretSync.OCIVault:
@@ -283,7 +283,7 @@ export const SecretSyncFns = {
}, },
getSecrets: async ( getSecrets: async (
secretSync: TSecretSyncWithCredentials, secretSync: TSecretSyncWithCredentials,
{ kmsService, appConnectionDAL }: TSyncSecretDeps { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps
): Promise<TSecretMap> => { ): Promise<TSecretMap> => {
let secretMap: TSecretMap; let secretMap: TSecretMap;
switch (secretSync.destination) { switch (secretSync.destination) {
@@ -341,7 +341,7 @@ export const SecretSyncFns = {
secretMap = await WindmillSyncFns.getSecrets(secretSync); secretMap = await WindmillSyncFns.getSecrets(secretSync);
break; break;
case SecretSync.HCVault: case SecretSync.HCVault:
secretMap = await HCVaultSyncFns.getSecrets(secretSync); secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService);
break; break;
case SecretSync.TeamCity: case SecretSync.TeamCity:
secretMap = await TeamCitySyncFns.getSecrets(secretSync); secretMap = await TeamCitySyncFns.getSecrets(secretSync);
@@ -451,7 +451,7 @@ export const SecretSyncFns = {
case SecretSync.Windmill: case SecretSync.Windmill:
return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap); return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.HCVault: case SecretSync.HCVault:
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService);
case SecretSync.TeamCity: case SecretSync.TeamCity:
return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.OCIVault: case SecretSync.OCIVault:

View File

@@ -1,7 +1,9 @@
import { Controller, FormProvider, useForm } from "react-hook-form"; import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod"; import { z } from "zod";
import { OrgPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
FormControl, FormControl,
@@ -9,9 +11,16 @@ import {
ModalClose, ModalClose,
SecretInput, SecretInput,
Select, Select,
SelectItem SelectItem,
Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription } from "@app/context";
import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { gatewaysQueryKeys } from "@app/hooks/api";
import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections"; import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -66,7 +75,8 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: appConnection ?? { defaultValues: appConnection ?? {
app: AppConnection.HCVault, app: AppConnection.HCVault,
method: HCVaultConnectionMethod.AppRole method: HCVaultConnectionMethod.AppRole,
gatewayId: null
} }
}); });
@@ -79,6 +89,9 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
const selectedMethod = watch("method"); const selectedMethod = watch("method");
const { subscription } = useSubscription();
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
@@ -115,6 +128,55 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
{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."
>
<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>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
)}
<Controller <Controller
name="credentials.instanceUrl" name="credentials.instanceUrl"
control={control} control={control}