diff --git a/backend/package.json b/backend/package.json index cc02186e9..aaef9f567 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,7 @@ "build": "tsup --sourcemap", "build:frontend": "npm run build --prefix ../frontend", "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": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", "test:unit": "vitest run -c vitest.unit.config.ts", diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 691f6c4c9..869364853 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -600,7 +600,7 @@ export const appConnectionServiceFactory = ({ azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), - hcvault: hcVaultConnectionService(connectAppConnectionById), + hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService), windmill: windmillConnectionService(connectAppConnectionById), teamcity: teamcityConnectionService(connectAppConnectionById), oci: ociConnectionService(connectAppConnectionById, licenseService), diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 48695a2a5..6b12c3aed 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -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 { BadRequestError } from "@app/lib/errors"; 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 { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { - THCVaultConnection, - THCVaultConnectionConfig, - THCVaultMountResponse, - TValidateHCVaultConnectionCredentials -} from "./hc-vault-connection-types"; +import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -37,7 +37,77 @@ type TokenRespData = { }; }; -export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => { +export const requestWithHCVaultGateway = async ( + appConnection: { gatewayId?: string | null }, + gatewayService: Pick, + requestConfig: AxiosRequestConfig +): Promise> => { + 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 +) => { // Return access token directly if not using AppRole method if (connection.method !== HCVaultConnectionMethod.AppRole) { return connection.credentials.accessToken; @@ -46,16 +116,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti // Generate temporary token for AppRole method try { const { instanceUrl, roleId, secretId } = connection.credentials; - const tokenResp = await request.post( - `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, - { role_id: roleId, secret_id: secretId }, - { - headers: { - "Content-Type": "application/json", - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) - } - } - ); + + const tokenResp = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, + method: "POST", + headers: { + "Content-Type": "application/json", + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + }, + data: { role_id: roleId, secret_id: secretId } + }); if (tokenResp.status !== 200) { throw new BadRequestError({ @@ -71,38 +141,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti } }; -export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => { - const instanceUrl = await getHCVaultInstanceUrl(config); +export const validateHCVaultConnectionCredentials = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); try { - const accessToken = await getHCVaultAccessToken(config); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); // 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 } }); - return config.credentials; + return connection.credentials; } catch (error: unknown) { + logger.error(error, "Unable to verify HC Vault connection"); + if (error instanceof AxiosError) { throw new BadRequestError({ message: `Failed to validate credentials: ${error.message || "Unknown error"}` }); } + + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ - message: "Unable to validate connection: verify credentials" + message: `Unable to validate connection: verify credentials` }); } }; -export const listHCVaultMounts = async (appConnection: THCVaultConnection) => { - const instanceUrl = await getHCVaultInstanceUrl(appConnection); - const accessToken = await getHCVaultAccessToken(appConnection); +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); - const { data } = await request.get(`${instanceUrl}/v1/sys/mounts`, { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/mounts`, + method: "GET", headers: { "X-Vault-Token": accessToken, - ...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {}) + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) } }); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts index bac799a17..57e8fbeaf 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts @@ -88,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion(" ]); export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault) + GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }) ); export const UpdateHCVaultConnectionSchema = z @@ -98,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z .optional() .describe(AppConnections.UPDATE(AppConnection.HCVault).credentials) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault)); + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })); export const HCVaultConnectionListItemSchema = z.object({ name: z.literal("HCVault"), diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index b5cee6fdd..589c7c1bd 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -1,3 +1,4 @@ +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; @@ -11,12 +12,15 @@ type TGetAppConnectionFunc = ( actor: OrgServiceActor ) => Promise; -export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +export const hcVaultConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + gatewayService: Pick +) => { const listMounts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor); try { - const mounts = await listHCVaultMounts(appConnection); + const mounts = await listHCVaultMounts(appConnection, gatewayService); return mounts; } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index 724eec7be..73c3a0699 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -1,5 +1,6 @@ import { isAxiosError } from "axios"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { request } from "@app/lib/config/request"; import { removeTrailingSlash } from "@app/lib/fn"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; @@ -65,7 +66,11 @@ const updateHCVaultVariables = async ({ }; export const HCVaultSyncFns = { - syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + syncSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, environment, @@ -74,7 +79,7 @@ export const HCVaultSyncFns = { } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); 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 + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); 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 + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); const variables = await listHCVaultVariables({ diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 3ff7cefbc..5b083baf6 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -244,7 +244,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: @@ -283,7 +283,7 @@ export const SecretSyncFns = { }, getSecrets: async ( secretSync: TSecretSyncWithCredentials, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { let secretMap: TSecretMap; switch (secretSync.destination) { @@ -341,7 +341,7 @@ export const SecretSyncFns = { secretMap = await WindmillSyncFns.getSecrets(secretSync); break; case SecretSync.HCVault: - secretMap = await HCVaultSyncFns.getSecrets(secretSync); + secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService); break; case SecretSync.TeamCity: secretMap = await TeamCitySyncFns.getSecrets(secretSync); @@ -451,7 +451,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx index af3954b45..07c323e8a 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HCVaultConnectionForm.tsx @@ -1,7 +1,9 @@ import { Controller, FormProvider, useForm } from "react-hook-form"; 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, @@ -9,9 +11,16 @@ import { ModalClose, SecretInput, Select, - SelectItem + SelectItem, + Tooltip } 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 { gatewaysQueryKeys } from "@app/hooks/api"; import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; @@ -66,7 +75,8 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => { resolver: zodResolver(formSchema), defaultValues: appConnection ?? { 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 { subscription } = useSubscription(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + return (
@@ -115,6 +128,55 @@ export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> + {subscription.gateway && ( + + {(isAllowed) => ( + ( + + + + + + )} + /> + )} + + )}