From aab204a68a4d3d3def6f04d0c5f97a016886c57c Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 16 Jul 2025 01:33:22 -0400 Subject: [PATCH] feat(app-connection): Gateway support for SQL connections --- .../oracledb/oracledb-connection-schemas.ts | 1 + backend/src/server/routes/index.ts | 3 +- .../app-connection/app-connection-service.ts | 11 ++- .../app-connection/app-connection-types.ts | 4 +- .../mssql/mssql-connection-schemas.ts | 1 + .../mysql/mysql-connection-schemas.ts | 1 + .../postgres/postgres-connection-schemas.ts | 1 + .../shared/sql/sql-connection-fns.ts | 98 +++++++++++++++---- .../shared/sql/sql-connection-schemas.ts | 1 + .../types/shared/sql-connection.ts | 1 + .../AppConnectionForm/MsSqlConnectionForm.tsx | 60 +++++++++++- .../AppConnectionForm/MySqlConnectionForm.tsx | 60 +++++++++++- .../OracleDBConnectionForm.tsx | 60 +++++++++++- .../PostgresConnectionForm.tsx | 60 +++++++++++- .../shared/sql-connection-schemas.ts | 1 + 15 files changed, 333 insertions(+), 30 deletions(-) diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts index f93abae83..ed95ba0da 100644 --- a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts @@ -24,6 +24,7 @@ export const SanitizedOracleDBConnectionSchema = z.discriminatedUnion("method", BaseOracleDBConnectionSchema.extend({ method: z.literal(OracleDBConnectionMethod.UsernameAndPassword), credentials: OracleDBConnectionCredentialsSchema.pick({ + gatewayId: true, host: true, database: true, port: true, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dcaa2b654..9fc796554 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1706,7 +1706,8 @@ export const registerRoutes = async ( appConnectionDAL, permissionService, kmsService, - licenseService + licenseService, + gatewayService }); const secretSyncService = secretSyncServiceFactory({ diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index fdb861b95..faa813557 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci"; import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service"; import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionAppConnectionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -92,6 +93,7 @@ export type TAppConnectionServiceFactoryDep = { permissionService: Pick; kmsService: Pick; licenseService: Pick; + gatewayService: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -135,7 +137,8 @@ export const appConnectionServiceFactory = ({ appConnectionDAL, permissionService, kmsService, - licenseService + licenseService, + gatewayService }: TAppConnectionServiceFactoryDep) => { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -273,7 +276,8 @@ export const appConnectionServiceFactory = ({ credentials: validatedCredentials, method } as TAppConnectionConfig, - (platformCredentials) => createConnection(platformCredentials) + (platformCredentials) => createConnection(platformCredentials), + gatewayService ); } else { connection = await createConnection(validatedCredentials); @@ -387,7 +391,8 @@ export const appConnectionServiceFactory = ({ credentials: updatedCredentials, method } as TAppConnectionConfig, - (platformCredentials) => updateConnection(platformCredentials) + (platformCredentials) => updateConnection(platformCredentials), + gatewayService ); } else { updatedConnection = await updateConnection(updatedCredentials); diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 4c9be6b8e..ffc814529 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -9,6 +9,7 @@ import { TOracleDBConnectionInput, TValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -354,7 +355,8 @@ export type TAppConnectionCredentialsValidator = ( export type TAppConnectionTransitionCredentialsToPlatform = ( appConnection: TAppConnectionConfig, - callback: (credentials: TAppConnection["credentials"]) => Promise + callback: (credentials: TAppConnection["credentials"]) => Promise, + gatewayService: Pick ) => Promise; export type TAppConnectionBaseConfig = { diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts index 994f9a40d..e48b42286 100644 --- a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -26,6 +26,7 @@ export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [ BaseMsSqlConnectionSchema.extend({ method: z.literal(MsSqlConnectionMethod.UsernameAndPassword), credentials: MsSqlConnectionAccessTokenCredentialsSchema.pick({ + gatewayId: true, host: true, database: true, port: true, diff --git a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts index 082bac557..0196f2f02 100644 --- a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts +++ b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts @@ -24,6 +24,7 @@ export const SanitizedMySqlConnectionSchema = z.discriminatedUnion("method", [ BaseMySqlConnectionSchema.extend({ method: z.literal(MySqlConnectionMethod.UsernameAndPassword), credentials: MySqlConnectionAccessTokenCredentialsSchema.pick({ + gatewayId: true, host: true, database: true, port: true, diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts index 1ddf1e2da..a9edf7710 100644 --- a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -24,6 +24,7 @@ export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", BasePostgresConnectionSchema.extend({ method: z.literal(PostgresConnectionMethod.UsernameAndPassword), credentials: PostgresConnectionAccessTokenCredentialsSchema.pick({ + gatewayId: true, host: true, database: true, port: true, diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index 33cc8257d..86953edce 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -1,11 +1,13 @@ import knex, { Knex } from "knex"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TSqlCredentialsRotationGeneratedCredentials, TSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types"; @@ -98,25 +100,80 @@ export const getSqlConnectionClient = async (appConnection: Pick { +const executeWithPotentialGateway = async ( + config: TSqlConnectionConfig, + gatewayService: Pick, + operation: (client: Knex) => Promise +): Promise => { const { credentials, app } = config; - let client: Knex | undefined; + if (credentials.gatewayId && gatewayService) { + const [targetHost] = await verifyHostInputValidity(credentials.host, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(credentials.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + return withGatewayProxy( + async (proxyPort) => { + const client = knex({ + client: SQL_CONNECTION_CLIENT_MAP[app], + connection: { + database: credentials.database, + port: proxyPort, + host: "localhost", + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ...getConnectionConfig({ app, credentials }) + } + }); + try { + return await operation(client); + } finally { + await client.destroy(); + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: credentials.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 + const client = await getSqlConnectionClient({ app, credentials }); try { - client = await getSqlConnectionClient({ app, credentials }); + return await operation(client); + } finally { + await client.destroy(); + } +}; - await client.raw(`Select 1`); - - return credentials; +export const validateSqlConnectionCredentials = async ( + config: TSqlConnectionConfig & { gatewayId?: string }, + gatewayService: Pick +) => { + try { + await executeWithPotentialGateway(config, gatewayService, async (client) => { + await client.raw(`Select 1`); + }); + return config.credentials; } catch (error) { throw new BadRequestError({ message: `Unable to validate connection: ${ - (error as Error)?.message?.replaceAll(credentials.password, "********************") ?? "verify credentials" + (error as Error)?.message?.replaceAll(config.credentials.password, "********************") ?? + "verify credentials" }` }); - } finally { - await client?.destroy(); } }; @@ -132,22 +189,23 @@ export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< export const transferSqlConnectionCredentialsToPlatform = async ( config: TSqlConnectionConfig, - callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise + callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise, + gatewayService: Pick ) => { const { credentials, app } = config; - const client = await getSqlConnectionClient({ app, credentials }); - const newPassword = alphaNumericNanoId(32); try { - return await client.transaction(async (tx) => { - await tx.raw( - ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) - ); - return callback({ - ...credentials, - password: newPassword + return await executeWithPotentialGateway(config, gatewayService, (client) => { + return client.transaction(async (tx) => { + await tx.raw( + ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) + ); + return callback({ + ...credentials, + password: newPassword + }); }); }); } catch (error) { @@ -161,7 +219,5 @@ export const transferSqlConnectionCredentialsToPlatform = async ( (error as Error)?.message?.replaceAll(newPassword, "********************") ?? "Encountered an error transferring credentials to platform" }); - } finally { - await client.destroy(); } }; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts index 500ed596a..bfaf6f2bc 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { AppConnections } from "@app/lib/api-docs"; export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({ + gatewayId: z.string().optional(), host: z.string().trim().min(1, "Host required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.host), port: z.coerce.number().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.port), database: z.string().trim().min(1, "Database required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.database), diff --git a/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts b/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts index d0e71158e..f88d8f726 100644 --- a/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/shared/sql-connection.ts @@ -1,4 +1,5 @@ export type TBaseSqlConnectionCredentials = { + gatewayId?: string; host: string; port: number; username: string; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx index f7d48744d..721ff30d4 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MsSqlConnectionForm.tsx @@ -1,10 +1,17 @@ import { useState } from "react"; 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 { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, ModalClose, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { MsSqlConnectionMethod, @@ -52,6 +59,7 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { app: AppConnection.MsSql, method: MsSqlConnectionMethod.UsernameAndPassword, credentials: { + gatewayId: "", host: "", port: 1433, database: "default", @@ -71,6 +79,7 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -121,6 +130,55 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ app: AppConnection.MySql, method: MySqlConnectionMethod.UsernameAndPassword, credentials: { + gatewayId: "", host: "", port: 3306, database: "default", @@ -68,6 +76,7 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -118,6 +127,55 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ app: AppConnection.OracleDB, method: OracleDBConnectionMethod.UsernameAndPassword, credentials: { + gatewayId: "", host: "", port: 1521, database: "ORCL", // Typically FREEPDB1 or ORCL @@ -68,6 +76,7 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -118,6 +127,55 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ app: AppConnection.Postgres, method: PostgresConnectionMethod.UsernameAndPassword, credentials: { + gatewayId: "", host: "", port: 5432, database: "default", @@ -68,6 +76,7 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => { } = form; const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false; + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const confirmSubmit = async (formData: FormData) => { if (formData.isPlatformManagedCredentials) { @@ -118,6 +127,55 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => { )} /> + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +