diff --git a/backend/src/db/migrations/20250717195959_gatewayid-for-app-conn.ts b/backend/src/db/migrations/20250717195959_gatewayid-for-app-conn.ts new file mode 100644 index 000000000..531cdcae8 --- /dev/null +++ b/backend/src/db/migrations/20250717195959_gatewayid-for-app-conn.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.AppConnection, "gatewayId"))) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.uuid("gatewayId").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AppConnection, "gatewayId")) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.dropColumn("gatewayId"); + }); + } +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts index ee4282b73..2218b75ce 100644 --- a/backend/src/db/schemas/app-connections.ts +++ b/backend/src/db/schemas/app-connections.ts @@ -20,7 +20,8 @@ export const AppConnectionsSchema = z.object({ orgId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - isPlatformManagedCredentials: z.boolean().default(false).nullable().optional() + isPlatformManagedCredentials: z.boolean().default(false).nullable().optional(), + gatewayId: z.string().uuid().nullable().optional() }); export type TAppConnections = z.infer; 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..38e0fc828 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 @@ -45,7 +45,10 @@ export const ValidateOracleDBConnectionCredentialsSchema = z.discriminatedUnion( ]); export const CreateOracleDBConnectionSchema = ValidateOracleDBConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.OracleDB, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.OracleDB, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdateOracleDBConnectionSchema = z @@ -54,7 +57,12 @@ export const UpdateOracleDBConnectionSchema = z AppConnections.UPDATE(AppConnection.OracleDB).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OracleDB, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.OracleDB, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const OracleDBConnectionListItemSchema = z.object({ name: z.literal("OracleDB"), diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index cd88f889e..47438ede2 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -4,6 +4,7 @@ import isEqual from "lodash.isequal"; import { SecretType, TableName } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -107,6 +108,7 @@ export type TSecretRotationV2ServiceFactoryDep = { queueService: Pick; appConnectionDAL: Pick; folderCommitService: Pick; + gatewayService: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -148,7 +150,8 @@ export const secretRotationV2ServiceFactory = ({ keyStore, queueService, folderCommitService, - appConnectionDAL + appConnectionDAL, + gatewayService }: TSecretRotationV2ServiceFactoryDep) => { const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { const appCfg = getConfig(); @@ -461,7 +464,8 @@ export const secretRotationV2ServiceFactory = ({ rotationInterval: payload.rotationInterval } as TSecretRotationV2WithConnection, appConnectionDAL, - kmsService + kmsService, + gatewayService ); // even though we have a db constraint we want to check before any rotation of credentials is attempted @@ -824,7 +828,8 @@ export const secretRotationV2ServiceFactory = ({ connection: appConnection } as TSecretRotationV2WithConnection, appConnectionDAL, - kmsService + kmsService, + gatewayService ); const generatedCredentials = await decryptSecretRotationCredentials({ @@ -907,7 +912,8 @@ export const secretRotationV2ServiceFactory = ({ connection: appConnection } as TSecretRotationV2WithConnection, appConnectionDAL, - kmsService + kmsService, + gatewayService ); const updatedRotation = await rotationFactory.rotateCredentials( diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index 5547d4582..b90d0ed88 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -1,4 +1,5 @@ import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; import { OrderByDirection } from "@app/lib/types"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; @@ -239,7 +240,8 @@ export type TRotationFactory< > = ( secretRotation: T, appConnectionDAL: Pick, - kmsService: Pick + kmsService: Pick, + gatewayService: Pick ) => { issueCredentials: TRotationFactoryIssueCredentials; revokeCredentials: TRotationFactoryRevokeCredentials; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts index 12e9b5964..3e6e5d265 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TRotationFactory, TRotationFactoryGetSecretsPayload, @@ -5,7 +7,10 @@ import { TRotationFactoryRevokeCredentials, TRotationFactoryRotateCredentials } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; -import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql"; +import { + executeWithPotentialGateway, + SQL_CONNECTION_ALTER_LOGIN_STATEMENT +} from "@app/services/app-connection/shared/sql"; import { generatePassword } from "../utils"; import { @@ -30,7 +35,7 @@ const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGenerat export const sqlCredentialsRotationFactory: TRotationFactory< TSqlCredentialsRotationWithConnection, TSqlCredentialsRotationGeneratedCredentials -> = (secretRotation) => { +> = (secretRotation, _appConnectionDAL, _kmsService, gatewayService) => { const { connection, parameters: { username1, username2 }, @@ -38,29 +43,38 @@ export const sqlCredentialsRotationFactory: TRotationFactory< secretsMapping } = secretRotation; - const $validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { - const client = await getSqlConnectionClient({ - ...connection, - credentials: { - ...connection.credentials, - ...credentials - } - }); + const executeOperation = ( + operation: (client: Knex) => Promise, + credentialsOverride?: TSqlCredentialsRotationGeneratedCredentials[number] + ) => { + const finalCredentials = { + ...connection.credentials, + ...credentialsOverride + }; + return executeWithPotentialGateway( + { + ...connection, + credentials: finalCredentials + }, + gatewayService, + (client) => operation(client) + ); + }; + + const $validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { try { - await client.raw("SELECT 1"); + await executeOperation(async (client) => { + await client.raw("SELECT 1"); + }, credentials); } catch (error) { throw new Error(redactPasswords(error, [credentials])); - } finally { - await client.destroy(); } }; const issueCredentials: TRotationFactoryIssueCredentials = async ( callback ) => { - const client = await getSqlConnectionClient(connection); - // For SQL, since we get existing users, we change both their passwords // on issue to invalidate their existing passwords const credentialsSet = [ @@ -69,15 +83,15 @@ export const sqlCredentialsRotationFactory: TRotationFactory< ]; try { - await client.transaction(async (tx) => { - for await (const credentials of credentialsSet) { - await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); - } + await executeOperation(async (client) => { + await client.transaction(async (tx) => { + for await (const credentials of credentialsSet) { + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); }); } catch (error) { throw new Error(redactPasswords(error, credentialsSet)); - } finally { - await client.destroy(); } for await (const credentials of credentialsSet) { @@ -91,21 +105,19 @@ export const sqlCredentialsRotationFactory: TRotationFactory< credentialsToRevoke, callback ) => { - const client = await getSqlConnectionClient(connection); - const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() })); try { - await client.transaction(async (tx) => { - for await (const credentials of revokedCredentials) { - // invalidate previous passwords - await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); - } + await executeOperation(async (client) => { + await client.transaction(async (tx) => { + for await (const credentials of revokedCredentials) { + // invalidate previous passwords + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); }); } catch (error) { throw new Error(redactPasswords(error, revokedCredentials)); - } finally { - await client.destroy(); } return callback(); @@ -115,17 +127,15 @@ export const sqlCredentialsRotationFactory: TRotationFactory< _, callback ) => { - const client = await getSqlConnectionClient(connection); - // generate new password for the next active user const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() }; try { - await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + await executeOperation(async (client) => { + await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + }); } catch (error) { throw new Error(redactPasswords(error, [credentials])); - } finally { - await client.destroy(); } await $validateCredentials(credentials); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dcaa2b654..4f7ebeed9 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1706,7 +1706,9 @@ export const registerRoutes = async ( appConnectionDAL, permissionService, kmsService, - licenseService + licenseService, + gatewayService, + gatewayDAL }); const secretSyncService = secretSyncServiceFactory({ @@ -1804,7 +1806,8 @@ export const registerRoutes = async ( snapshotService, secretQueueService, queueService, - appConnectionDAL + appConnectionDAL, + gatewayService }); const certificateAuthorityService = certificateAuthorityServiceFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index 0bc8f7c59..50111b109 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -25,12 +25,14 @@ export const registerAppConnectionEndpoints = ; updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null; isPlatformManagedCredentials?: boolean; + gatewayId?: string | null; }>; sanitizedResponseSchema: z.ZodTypeAny; }) => { @@ -224,10 +226,10 @@ export const registerAppConnectionEndpoints = { - const { name, method, credentials, description, isPlatformManagedCredentials } = req.body; + const { name, method, credentials, description, isPlatformManagedCredentials, gatewayId } = req.body; const appConnection = (await server.services.appConnection.createAppConnection( - { name, method, app, credentials, description, isPlatformManagedCredentials }, + { name, method, app, credentials, description, isPlatformManagedCredentials, gatewayId }, req.permission )) as T; @@ -270,11 +272,11 @@ export const registerAppConnectionEndpoints = { - const { name, credentials, description, isPlatformManagedCredentials } = req.body; + const { name, credentials, description, isPlatformManagedCredentials, gatewayId } = req.body; const { connectionId } = req.params; const appConnection = (await server.services.appConnection.updateAppConnection( - { name, credentials, connectionId, description, isPlatformManagedCredentials }, + { name, credentials, connectionId, description, isPlatformManagedCredentials, gatewayId }, req.permission )) as T; diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 10bab521e..54aac2001 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -5,6 +5,7 @@ import { validateOCIConnectionCredentials } from "@app/ee/services/app-connections/oci"; import { getOracleDBConnectionListItem, OracleDBConnectionMethod } 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 { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; @@ -201,7 +202,8 @@ export const decryptAppConnectionCredentials = async ({ }; export const validateAppConnectionCredentials = async ( - appConnection: TAppConnectionConfig + appConnection: TAppConnectionConfig, + gatewayService: Pick ): Promise => { const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, @@ -242,7 +244,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator }; - return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); + return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); }; export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts index 0d3968637..d0dcb1a54 100644 --- a/backend/src/services/app-connection/app-connection-schemas.ts +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -18,7 +18,7 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ export const GenericCreateAppConnectionFieldsSchema = ( app: AppConnection, - { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} + { supportsPlatformManagedCredentials = false, supportsGateways = false }: TAppConnectionBaseConfig = {} ) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), @@ -30,12 +30,23 @@ export const GenericCreateAppConnectionFieldsSchema = ( .describe(AppConnections.CREATE(app).description), isPlatformManagedCredentials: supportsPlatformManagedCredentials ? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials) - : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) + : z + .literal(false, { + errorMap: () => ({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + }) + .optional() + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`), + gatewayId: supportsGateways + ? z.string().uuid().nullish().describe("The Gateway ID to use for this connection.") + : z + .undefined({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + .or(z.null({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` })) + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); export const GenericUpdateAppConnectionFieldsSchema = ( app: AppConnection, - { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} + { supportsPlatformManagedCredentials = false, supportsGateways = false }: TAppConnectionBaseConfig = {} ) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), @@ -47,5 +58,16 @@ export const GenericUpdateAppConnectionFieldsSchema = ( .describe(AppConnections.UPDATE(app).description), isPlatformManagedCredentials: supportsPlatformManagedCredentials ? z.boolean().optional().describe(AppConnections.UPDATE(app).isPlatformManagedCredentials) - : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) + : z + .literal(false, { + errorMap: () => ({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + }) + .optional() + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`), + gatewayId: supportsGateways + ? z.string().uuid().nullish().describe("The Gateway ID to use for this connection.") + : z + .undefined({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` }) + .or(z.null({ message: `Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections` })) + .describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 1f3e76f8e..bfff562d7 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -3,8 +3,14 @@ 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 { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; +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 { + OrgPermissionAppConnectionActions, + OrgPermissionGatewayActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; import { DatabaseErrorCode } from "@app/lib/error-codes"; @@ -96,6 +102,8 @@ export type TAppConnectionServiceFactoryDep = { permissionService: Pick; kmsService: Pick; licenseService: Pick; + gatewayService: Pick; + gatewayDAL: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -141,7 +149,9 @@ export const appConnectionServiceFactory = ({ appConnectionDAL, permissionService, kmsService, - licenseService + licenseService, + gatewayService, + gatewayDAL }: TAppConnectionServiceFactoryDep) => { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -222,7 +232,7 @@ export const appConnectionServiceFactory = ({ }; const createAppConnection = async ( - { method, app, credentials, ...params }: TCreateAppConnectionDTO, + { method, app, credentials, gatewayId, ...params }: TCreateAppConnectionDTO, actor: OrgServiceActor ) => { const { permission } = await permissionService.getOrgPermission( @@ -238,6 +248,20 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); + if (gatewayId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actor.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found for org` + }); + } + } + await enterpriseAppCheck( licenseService, app, @@ -245,12 +269,16 @@ export const appConnectionServiceFactory = ({ "Failed to create app connection due to plan restriction. Upgrade plan to access enterprise app connections." ); - const validatedCredentials = await validateAppConnectionCredentials({ - app, - credentials, - method, - orgId: actor.orgId - } as TAppConnectionConfig); + const validatedCredentials = await validateAppConnectionCredentials( + { + app, + credentials, + method, + orgId: actor.orgId, + gatewayId + } as TAppConnectionConfig, + gatewayService + ); try { const createConnection = async (connectionCredentials: TAppConnection["credentials"]) => { @@ -265,6 +293,7 @@ export const appConnectionServiceFactory = ({ encryptedCredentials, method, app, + gatewayId, ...params }); }; @@ -277,9 +306,11 @@ export const appConnectionServiceFactory = ({ app, orgId: actor.orgId, credentials: validatedCredentials, - method + method, + gatewayId } as TAppConnectionConfig, - (platformCredentials) => createConnection(platformCredentials) + (platformCredentials) => createConnection(platformCredentials), + gatewayService ); } else { connection = await createConnection(validatedCredentials); @@ -300,7 +331,7 @@ export const appConnectionServiceFactory = ({ }; const updateAppConnection = async ( - { connectionId, credentials, ...params }: TUpdateAppConnectionDTO, + { connectionId, credentials, gatewayId, ...params }: TUpdateAppConnectionDTO, actor: OrgServiceActor ) => { const appConnection = await appConnectionDAL.findById(connectionId); @@ -327,6 +358,22 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); + if (gatewayId !== appConnection.gatewayId) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.AttachGateways, + OrgPermissionSubjects.Gateway + ); + + if (gatewayId) { + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actor.orgId }); + if (!gateway) { + throw new NotFoundError({ + message: `Gateway with ID ${gatewayId} not found for org` + }); + } + } + } + // prevent updating credentials or management status if platform managed if (appConnection.isPlatformManagedCredentials && (params.isPlatformManagedCredentials === false || credentials)) { throw new BadRequestError({ @@ -351,12 +398,16 @@ export const appConnectionServiceFactory = ({ } Connection with method ${getAppConnectionMethodName(method)}` }); - updatedCredentials = await validateAppConnectionCredentials({ - app, - orgId: actor.orgId, - credentials, - method - } as TAppConnectionConfig); + updatedCredentials = await validateAppConnectionCredentials( + { + app, + orgId: actor.orgId, + credentials, + method, + gatewayId + } as TAppConnectionConfig, + gatewayService + ); if (!updatedCredentials) throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); @@ -375,6 +426,7 @@ export const appConnectionServiceFactory = ({ return appConnectionDAL.updateById(connectionId, { orgId: actor.orgId, encryptedCredentials, + gatewayId, ...params }); }; @@ -391,9 +443,11 @@ export const appConnectionServiceFactory = ({ app, orgId: actor.orgId, credentials: updatedCredentials, - method + method, + gatewayId } 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 bb6be0a70..077909730 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"; @@ -282,7 +283,7 @@ export type TSqlConnectionInput = export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, - "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" + "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" | "gatewayId" >; export type TUpdateAppConnectionDTO = Partial> & { @@ -369,14 +370,17 @@ export type TListAwsConnectionIamUsers = { }; export type TAppConnectionCredentialsValidator = ( - appConnection: TAppConnectionConfig + appConnection: TAppConnectionConfig, + gatewayService: Pick ) => Promise; export type TAppConnectionTransitionCredentialsToPlatform = ( appConnection: TAppConnectionConfig, - callback: (credentials: TAppConnection["credentials"]) => Promise + callback: (credentials: TAppConnection["credentials"]) => Promise, + gatewayService: Pick ) => Promise; export type TAppConnectionBaseConfig = { supportsPlatformManagedCredentials?: boolean; + supportsGateways?: boolean; }; 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..f8d380949 100644 --- a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -49,7 +49,10 @@ export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("me ]); export const CreateMsSqlConnectionSchema = ValidateMsSqlConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdateMsSqlConnectionSchema = z @@ -58,7 +61,12 @@ export const UpdateMsSqlConnectionSchema = z AppConnections.UPDATE(AppConnection.MsSql).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const MsSqlConnectionListItemSchema = z.object({ name: z.literal("Microsoft SQL Server"), 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..51a533395 100644 --- a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts +++ b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts @@ -47,7 +47,10 @@ export const ValidateMySqlConnectionCredentialsSchema = z.discriminatedUnion("me ]); export const CreateMySqlConnectionSchema = ValidateMySqlConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.MySql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdateMySqlConnectionSchema = z @@ -56,7 +59,12 @@ export const UpdateMySqlConnectionSchema = z AppConnections.UPDATE(AppConnection.MySql).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.MySql, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const MySqlConnectionListItemSchema = z.object({ name: z.literal("MySQL"), 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..da74bd669 100644 --- a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -47,7 +47,10 @@ export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion( ]); export const CreatePostgresConnectionSchema = ValidatePostgresConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true }) + GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) ); export const UpdatePostgresConnectionSchema = z @@ -56,7 +59,12 @@ export const UpdatePostgresConnectionSchema = z AppConnections.UPDATE(AppConnection.Postgres).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true })); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); export const PostgresConnectionListItemSchema = z.object({ name: z.literal("PostgreSQL"), 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..d9adc91dd 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 { credentials, app } = config; +export const executeWithPotentialGateway = async ( + config: TSqlConnectionConfig, + gatewayService: Pick, + operation: (client: Knex) => Promise +): Promise => { + const { credentials, app, gatewayId } = config; - let client: Knex | undefined; + if (gatewayId && gatewayService) { + const [targetHost] = await verifyHostInputValidity(credentials.host, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(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, + 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-types.ts b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts index bbfe4086c..104aacfb9 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-types.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts @@ -1,6 +1,9 @@ import { DiscriminativePick } from "@app/lib/types"; import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types"; -export type TSqlConnectionConfig = DiscriminativePick & { +export type TSqlConnectionConfig = DiscriminativePick< + TSqlConnectionInput, + "method" | "app" | "credentials" | "gatewayId" +> & { orgId: string; }; diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 8a9039e2e..9140136a0 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -1,4 +1,6 @@ /* eslint-disable no-await-in-loop */ +import { isAxiosError } from "axios"; + import { request } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; @@ -71,7 +73,7 @@ const putEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secr ); }; -const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: TRenderSecret) => { +const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: Pick) => { const { destinationConfig, connection: { @@ -79,15 +81,24 @@ const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, s } } = secretSync; - await request.delete( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + try { + await request.delete( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } } + ); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + // If the secret does not exist, we can ignore this error + return; } - ); + + throw error; + } }; const sleep = async () => @@ -99,6 +110,11 @@ export const RenderSyncFns = { syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { const renderSecrets = await getRenderEnvironmentSecrets(secretSync); for await (const key of Object.keys(secretMap)) { + // If value is empty skip it as render does not allow empty variables + if (secretMap[key].value === "") { + // eslint-disable-next-line no-continue + continue; + } await putEnvironmentSecret(secretSync, secretMap, key); await sleep(); } diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index e177fa2ab..3fc659793 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -113,11 +113,20 @@ export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppCo export type TCreateAppConnectionDTO = Pick< TAppConnection, - "name" | "credentials" | "method" | "app" | "description" | "isPlatformManagedCredentials" + | "name" + | "credentials" + | "method" + | "app" + | "description" + | "isPlatformManagedCredentials" + | "gatewayId" >; export type TUpdateAppConnectionDTO = Partial< - Pick + Pick< + TAppConnection, + "name" | "credentials" | "description" | "isPlatformManagedCredentials" | "gatewayId" + > > & { connectionId: string; app: AppConnection; diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts index 52571d067..5b8f5cd02 100644 --- a/frontend/src/hooks/api/appConnections/types/root-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -7,4 +7,5 @@ export type TRootAppConnection = { createdAt: string; updatedAt: string; isPlatformManagedCredentials?: boolean; + gatewayId?: string | null; }; diff --git a/frontend/src/hooks/api/folderCommits/queries.tsx b/frontend/src/hooks/api/folderCommits/queries.tsx index 8d0883c5b..1bca7ce9a 100644 --- a/frontend/src/hooks/api/folderCommits/queries.tsx +++ b/frontend/src/hooks/api/folderCommits/queries.tsx @@ -1,8 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; import { apiRequest } from "@app/config/request"; -import { CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types"; +import { Commit, CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types"; export const commitKeys = { count: ({ @@ -242,7 +243,6 @@ export const useGetFolderCommitHistory = ({ workspaceId, environment, directory, - offset = 0, limit = 20, search, sort = "desc" @@ -250,22 +250,33 @@ export const useGetFolderCommitHistory = ({ workspaceId: string; environment: string; directory: string; - offset?: number; limit?: number; search?: string; sort?: "asc" | "desc"; }) => { - return useQuery({ - queryKey: [ - commitKeys.history({ workspaceId, environment, directory }), - offset, - limit, - search, - sort - ], - queryFn: () => - fetchFolderCommitHistory(workspaceId, environment, directory, offset, limit, search, sort), - enabled: Boolean(workspaceId && environment) + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: [commitKeys.history({ workspaceId, environment, directory }), limit, search, sort], + queryFn: ({ pageParam }) => + fetchFolderCommitHistory(workspaceId, environment, directory, pageParam, limit, search, sort), + enabled: Boolean(workspaceId && environment), + select: (data) => { + return (data?.pages ?? []) + ?.map((page) => page.commits) + .flat() + .reduce( + (acc, commit) => { + const date = format(new Date(commit.createdAt), "MMM d, yyyy"); + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(commit); + return acc; + }, + {} as Record + ); + }, + getNextPageParam: (lastPage, pages) => (lastPage.hasMore ? pages.length * limit : undefined) }); }; diff --git a/frontend/src/hooks/api/folderCommits/types.ts b/frontend/src/hooks/api/folderCommits/types.ts index 878e3224d..a5f4e6df6 100644 --- a/frontend/src/hooks/api/folderCommits/types.ts +++ b/frontend/src/hooks/api/folderCommits/types.ts @@ -62,3 +62,16 @@ export type RollbackPreview = { folderPath: string; changes: RollbackChange[]; }; + +interface CommitActorMetadata { + email?: string; + name?: string; +} + +export interface Commit { + id: string; + message: string; + createdAt: string; + actorType: string; + actorMetadata?: CommitActorMetadata; +} diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx index 70128025d..e7d9cf80c 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GenericAppConnectionFields.tsx @@ -6,7 +6,11 @@ import { slugSchema } from "@app/lib/schemas"; export const genericAppConnectionFieldsSchema = z.object({ name: slugSchema({ min: 1, max: 64, field: "Name" }), - description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() + description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish(), + gatewayId: z + .string() + .nullish() + .transform((v) => (v === "" ? null : v)) }); export const GenericAppConnectionsFields = () => { 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..0735a863c 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, @@ -51,6 +58,7 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { defaultValues: appConnection ?? { app: AppConnection.MsSql, method: MsSqlConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 1433, @@ -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) { @@ -90,6 +99,55 @@ export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ defaultValues: appConnection ?? { app: AppConnection.MySql, method: MySqlConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 3306, @@ -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) { @@ -87,6 +96,55 @@ export const MySqlConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ defaultValues: appConnection ?? { app: AppConnection.OracleDB, method: OracleDBConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 1521, @@ -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) { @@ -87,6 +96,55 @@ export const OracleDBConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
{ defaultValues: appConnection ?? { app: AppConnection.Postgres, method: PostgresConnectionMethod.UsernameAndPassword, + gatewayId: null, credentials: { host: "", port: 5432, @@ -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) { @@ -87,6 +96,55 @@ export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
-

Select a commit to view details

- + + + ); } if (isLoading) { - return ( -
- -
- ); + return ; } if (!commitDetails) { return ( -
-

No details found for this commit

-
+ + + ); } @@ -138,9 +145,11 @@ export const CommitDetailsTab = ({ } catch (error) { console.error("Failed to parse commit details:", error); return ( -
-

Error parsing commit details

-
+ + + ); } @@ -223,13 +232,12 @@ export const CommitDetailsTab = ({ // Render an item from the merged list const renderMergedItem = (item: MergedItem): JSX.Element => { return ( -
- toggleItemCollapsed(id)} - /> -
+ toggleItemCollapsed(id)} + /> ); }; @@ -240,114 +248,104 @@ export const CommitDetailsTab = ({ "Unknown"; return ( -
-
-
-
-
-
-

- {parsedCommitDetails.changes?.message || "No message"} -

-
-
-
-

- Commited by - {actorDisplay} - on - - {formatDisplayDate( - parsedCommitDetails.changes?.createdAt || new Date().toISOString() - )} - - {parsedCommitDetails.changes?.isLatest && ( - (Latest) - )} -

-
-
-
- - {(isAllowed) => ( - - + + + Commited by {actorDisplay} on{" "} + {formatDisplayDate(parsedCommitDetails.changes?.createdAt || new Date().toISOString())} + {parsedCommitDetails.changes?.isLatest && ( + (Latest) + )} + + } + > + + {(isAllowed) => ( + + + + + + {!parsedCommitDetails.changes.isLatest && ( + goToRollbackPreview()} > - -

Restore Options

- -
-
- - {!parsedCommitDetails.changes.isLatest && ( - goToRollbackPreview()} - > -
-
- - Roll back to this commit - - - Return this folder to its exact state at the time of this commit, - discarding all other changes made after it - -
-
-
- )} - - handlePopUpOpen("revertChanges")} - > -
-
- Revert changes - - Will restore to the previous version of affected resources - -
+
+
+ + Roll back to this commit + + + Return this folder to its exact state at the time of this commit, + discarding all other changes made after it +
- - - - )} - -
+
+
+ )} + handlePopUpOpen("revertChanges")} + > +
+
+ Revert changes + + Will restore to the previous version of affected resources + +
+
+
+
+
+ )} +
+ +
+
+

Commit Changes

- -
-
-
- {sortedChangedItems.length > 0 ? ( - sortedChangedItems.map((item) => renderMergedItem(item)) - ) : ( -
-

No changed items found

-
- )} -
+
+
+ {sortedChangedItems.length > 0 ? ( + sortedChangedItems.map((item) => renderMergedItem(item)) + ) : ( + + )}
- -
+ ); }; diff --git a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx index 7744d3626..a1d058ea3 100644 --- a/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx +++ b/frontend/src/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView/SecretVersionDiffView.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; export interface Version { id?: string; @@ -225,15 +226,12 @@ const renderJsonWithDiffs = ( const getLineClass = (different: boolean) => { if (!different) return "flex"; - return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"; + return isOldVersion + ? "flex bg-red-500/50 rounded-sm text-red-300" + : "flex bg-green-500/50 rounded-sm text-green-300"; }; - const getHighlightClass = (different: boolean) => { - if (!different) return ""; - return isOldVersion ? "bg-red-900 rounded px-1" : "bg-green-900 rounded px-1"; - }; - - const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " "; + const prefix = isDifferent ? (isOldVersion ? " -" : " +") : " "; const keyDisplay = keyName ? `"${keyName}": ` : ""; const comma = !isLastItem ? "," : ""; @@ -255,8 +253,8 @@ const renderJsonWithDiffs = (
{prefix}
{indent} - {keyName && {keyDisplay}} - {valueDisplay} + {keyName && {keyDisplay}} + {valueDisplay} {comma}
@@ -269,8 +267,8 @@ const renderJsonWithDiffs = (
{prefix}
{indent} - {keyName && {keyDisplay}} - [] + {keyName && {keyDisplay}} + [] {comma}
@@ -283,8 +281,8 @@ const renderJsonWithDiffs = (
{prefix}
{indent} - {keyName && {keyDisplay}} - {"{}"} + {keyName && {keyDisplay}} + {"{}"} {comma}
@@ -320,16 +318,12 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - {keyName && ( - - {keyDisplay} - - )} - [ + {keyName && {keyDisplay}} + [
@@ -357,11 +351,11 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - ] + ] {comma}
@@ -376,16 +370,12 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - {keyName && ( - - {keyDisplay} - - )} - {"{"} + {keyName && {keyDisplay}} + {"{"}
@@ -414,11 +404,11 @@ const renderJsonWithDiffs = (
- {isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "} + {isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
{indent} - {"}"} + {"}"} {comma}
@@ -527,7 +517,7 @@ export const SecretVersionDiffView = ({ } oldVersionContent = ( -
+
{renderJsonWithDiffs( cleanOldVersion, diffPaths, @@ -543,7 +533,7 @@ export const SecretVersionDiffView = ({
); newVersionContent = ( -
+
{renderJsonWithDiffs( cleanNewVersion, diffPaths, @@ -583,19 +573,19 @@ export const SecretVersionDiffView = ({ if (item.isDeleted) { textStyle = "line-through text-red-300"; changeBadge = ( - + {isSecret ? "Secret" : "Folder"} Deleted ); } else if (item.isAdded) { changeBadge = ( - + {isSecret ? "Secret" : "Folder"} Added ); } else if (item.isUpdated) { changeBadge = ( - + {isSecret ? "Secret" : "Folder"} Updated ); @@ -615,32 +605,34 @@ export const SecretVersionDiffView = ({ tabIndex={0} aria-expanded={!collapsed} > -
- {key} +
+

{key}

{changeBadge}
- +
); }; return ( -
+
{showHeader && renderHeader()} - {!collapsed && ( -
-
+
+
{oldVersionContent}
- +
{newVersionContent}
diff --git a/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx b/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx index 406a1b112..591c03d4d 100644 --- a/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx +++ b/frontend/src/pages/secret-manager/CommitsPage/CommitsPage.tsx @@ -52,7 +52,7 @@ export const CommitsPage = () => { title="Commits" description="Track, inspect, and restore your secrets and folders with confidence. View the complete history of changes made to your environment, examine specific modifications at each commit point, and preview the exact impact before rolling back to previous states." /> - +

Secret Snapshots have been officially renamed to Commits. Going forward, all secret changes will be tracked as Commits. If you made changes before this update, you can diff --git a/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx b/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx index 8f529ba86..58191f389 100644 --- a/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx +++ b/frontend/src/pages/secret-manager/CommitsPage/components/CommitHistoryTab/CommitHistoryTab.tsx @@ -1,29 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { faArrowDownWideShort, faArrowUpWideShort, + faCodeCommit, faCopy, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format, formatDistanceToNow } from "date-fns"; +import { formatDistanceToNow } from "date-fns"; -import { Button, Input, Spinner } from "@app/components/v2"; +import { Button, ContentLoader, EmptyState, IconButton, Input } from "@app/components/v2"; import { CopyButton } from "@app/components/v2/CopyButton"; -import { useGetFolderCommitHistory } from "@app/hooks/api/folderCommits"; - -interface CommitActorMetadata { - email?: string; - name?: string; -} - -interface Commit { - id: string; - message: string; - createdAt: string; - actorType: string; - actorMetadata?: CommitActorMetadata; -} +import { Commit, useGetFolderCommitHistory } from "@app/hooks/api/folderCommits"; const formatTimeAgo = (timestamp: string): string => { return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); @@ -40,58 +28,40 @@ const CommitItem = ({ onSelectCommit: (commitId: string, tab: string) => void; }) => { return ( -

-
-
-
-
- -
-

- - {commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType} -

committed

- - -

-
-
-
- - -
-
+
+ ); }; @@ -108,24 +78,16 @@ const DateGroup = ({ onSelectCommit: (commitId: string, tab: string) => void; }) => { return ( -
-
-
-
-
-
-

Commits on {date}

+
+
+ +

Commits on {date}

-
-
+
{commits.map((commit) => ( -
-
- -
-
+ ))}
@@ -147,10 +109,8 @@ export const CommitHistoryTab = ({ const [searchTerm, setSearchTerm] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); - const [offset, setOffset] = useState(0); - const [allCommits, setAllCommits] = useState([]); const debounceTimeoutRef = useRef(); - const limit = 5; + const limit = 10; // Debounce search term useEffect(() => { @@ -170,55 +130,20 @@ export const CommitHistoryTab = ({ }, [searchTerm]); const { - data: response, + data: groupedCommits, isLoading, - isFetching + fetchNextPage, + isFetchingNextPage, + hasNextPage } = useGetFolderCommitHistory({ workspaceId: projectId, environment, directory: secretPath, - offset, limit, search: debouncedSearchTerm, sort: sortDirection }); - const commits = response?.commits || []; - const hasMore = response?.hasMore || false; - - // Reset accumulated commits when search or sort changes - useEffect(() => { - setAllCommits([]); - setOffset(0); - }, [debouncedSearchTerm, sortDirection]); - - // Accumulate commits instead of replacing them - useEffect(() => { - if (commits.length > 0) { - if (offset === 0) { - // First load or after search/sort change - replace all commits - setAllCommits(commits); - } else { - // Subsequent loads - append new commits - setAllCommits((prev) => [...prev, ...commits]); - } - } - }, [commits, offset]); - - const groupedCommits = useMemo(() => { - return allCommits.reduce( - (acc, commit) => { - const date = format(new Date(commit.createdAt), "MMM d, yyyy"); - if (!acc[date]) { - acc[date] = []; - } - acc[date].push(commit); - return acc; - }, - {} as Record - ); - }, [allCommits]); - const handleSort = useCallback(() => { setSortDirection((prev) => (prev === "desc" ? "asc" : "desc")); }, []); @@ -227,50 +152,39 @@ export const CommitHistoryTab = ({ setSearchTerm(value); }, []); - const loadMoreCommits = useCallback(() => { - if (hasMore && !isFetching) { - setOffset((prev) => prev + limit); - } - }, [hasMore, isFetching, limit]); - return ( -
+
+

Commit History

} placeholder="Search commits..." - className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20" onChange={(e) => handleSearch(e.target.value)} value={searchTerm} aria-label="Search commits" /> -
-
- +
- - {isLoading && offset === 0 ? ( -
- -
+ {isLoading ? ( + ) : ( -
- {Object.keys(groupedCommits).length > 0 ? ( +
+ {groupedCommits && Object.keys(groupedCommits).length > 0 ? ( <> {Object.entries(groupedCommits).map(([date, dateCommits]) => ( ) : ( -
-
+ )} - - {hasMore && ( + {hasNextPage && (
)}