diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 65d0b7859..9f44e9da8 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -53,3 +53,34 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => { return result; }; + +export const deepEqual = (obj1: unknown, obj2: unknown): boolean => { + if (obj1 === obj2) return true; + + if (obj1 === null || obj2 === null || obj1 === undefined || obj2 === undefined) { + return obj1 === obj2; + } + + if (typeof obj1 !== typeof obj2) return false; + + if (typeof obj1 !== "object") return obj1 === obj2; + + if (Array.isArray(obj1) !== Array.isArray(obj2)) return false; + + if (Array.isArray(obj1)) { + const arr1 = obj1 as unknown[]; + const arr2 = obj2 as unknown[]; + if (arr1.length !== arr2.length) return false; + return arr1.every((val, idx) => deepEqual(val, arr2[idx])); + } + + const keys1 = Object.keys(obj1 as Record).sort(); + const keys2 = Object.keys(obj2 as Record).sort(); + + if (keys1.length !== keys2.length) return false; + if (keys1.some((key, idx) => key !== keys2[idx])) return false; + + return keys1.every((key) => + deepEqual((obj1 as Record)[key], (obj2 as Record)[key]) + ); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts index 9db3a2013..a86c07cf9 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts @@ -425,4 +425,39 @@ export const registerSyncSecretsEndpoints = { + const { destinationConfig, excludeSyncId, projectId } = req.body; + + const result = await server.services.secretSync.checkDuplicateDestination( + { + destinationConfig, + destination, + excludeSyncId, + projectId + }, + req.permission + ); + + return result; + } + }); }; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index ecd7d04a5..739b7c62c 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -12,6 +12,7 @@ import { import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { deepEqual } from "@app/lib/fn/object"; import { OrgServiceActor } from "@app/lib/types"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -20,6 +21,7 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncStatus, + TCheckDuplicateDestinationDTO, TCreateSecretSyncDTO, TDeleteSecretSyncDTO, TFindSecretSyncByIdDTO, @@ -696,6 +698,51 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; + const checkDuplicateDestination = async ( + { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + if (!destinationConfig || typeof destinationConfig !== "object") { + return { hasDuplicate: false }; + } + + try { + const existingSyncs = await secretSyncDAL.find({ + destination + }); + + const duplicates = existingSyncs.filter((sync) => { + if (sync.id === excludeSyncId) { + return false; + } + + try { + return deepEqual(sync.destinationConfig, destinationConfig); + } catch { + return false; + } + }); + + return { hasDuplicate: duplicates.length > 0 }; + } catch (error) { + return { hasDuplicate: false }; + } + }; + return { listSecretSyncOptions, listSecretSyncsByProjectId, @@ -707,6 +754,7 @@ export const secretSyncServiceFactory = ({ deleteSecretSync, triggerSecretSyncSyncSecretsById, triggerSecretSyncImportSecretsById, - triggerSecretSyncRemoveSecretsById + triggerSecretSyncRemoveSecretsById, + checkDuplicateDestination }; }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 6435e19d3..92384ff46 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = { removeSecrets: boolean; }; +export type TCheckDuplicateDestinationDTO = { + destination: SecretSync; + destinationConfig: unknown; + excludeSyncId?: string; + projectId: string; +}; + export enum SecretSyncStatus { Pending = "pending", Running = "running", diff --git a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx new file mode 100644 index 000000000..45ec2c1be --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx @@ -0,0 +1,47 @@ +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onConfirm: () => void; + isLoading?: boolean; +}; + +export const DuplicateDestinationConfirmationModal = ({ + isOpen, + onOpenChange, + onConfirm, + isLoading +}: Props) => { + return ( + + +
+

+ Another secret sync in your organization is already configured with the same + destination. Proceeding may cause conflicts or overwrite existing data. +

+

Are you sure you want to continue?

+
+ +
+ + + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index dc2ab385a..97c8d7acb 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -6,9 +6,14 @@ import { createNotification } from "@app/components/notifications"; import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; import { Button, ModalClose } from "@app/components/v2"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs"; +import { + TSecretSync, + useCheckDuplicateDestination, + useUpdateSecretSync +} from "@app/hooks/api/secretSyncs"; import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields"; +import { DuplicateDestinationConfirmationModal } from "./DuplicateDestinationConfirmationModal"; import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas"; import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; @@ -23,6 +28,8 @@ type Props = { export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => { const updateSecretSync = useUpdateSecretSync(); const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; + const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false); + const [pendingFormData, setPendingFormData] = useState(null); const formMethods = useForm({ resolver: zodResolver(UpdateSecretSyncFormSchema), @@ -35,11 +42,23 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => reValidateMode: "onChange" }); - const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => { + const [destinationConfigToCheck, setDestinationConfigToCheck] = useState(null); + const [checkDuplicateEnabled, setCheckDuplicateEnabled] = useState(false); + + const { data: hasDuplicate, isLoading: isCheckingDuplicate } = useCheckDuplicateDestination( + secretSync.destination, + destinationConfigToCheck, + secretSync.projectId, + secretSync.id, + { enabled: checkDuplicateEnabled && Boolean(destinationConfigToCheck) } + ); + + const performUpdate = async (formData: TSecretSyncForm) => { try { + const { environment, connection, ...updateData } = formData; const updatedSecretSync = await updateSecretSync.mutateAsync({ syncId: secretSync.id, - ...formData, + ...updateData, environment: environment?.slug, connectionId: connection.id, projectId: secretSync.projectId @@ -60,6 +79,73 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => } }; + useEffect(() => { + if (checkDuplicateEnabled && !isCheckingDuplicate && destinationConfigToCheck) { + if (hasDuplicate) { + setShowDuplicateConfirmation(true); + } else if (pendingFormData) { + performUpdate(pendingFormData); + setPendingFormData(null); + } + setCheckDuplicateEnabled(false); + setDestinationConfigToCheck(null); + } + }, [ + checkDuplicateEnabled, + isCheckingDuplicate, + hasDuplicate, + destinationConfigToCheck, + pendingFormData, + performUpdate + ]); + + const normalizeConfig = (config: unknown): unknown => { + if (config === null || config === undefined || typeof config !== "object") { + return config; + } + + if (Array.isArray(config)) { + return config.map(normalizeConfig); + } + + const normalized: Record = {}; + Object.keys(config as Record) + .sort() + .forEach((key) => { + normalized[key] = normalizeConfig((config as Record)[key]); + }); + + return normalized; + }; + + const hasDestinationConfigChanged = (formData: TSecretSyncForm) => { + const originalConfig = normalizeConfig(secretSync.destinationConfig); + const currentConfig = normalizeConfig(formData.destinationConfig); + + return JSON.stringify(originalConfig) !== JSON.stringify(currentConfig); + }; + + const onSubmit = async (formData: TSecretSyncForm) => { + if (fields === SecretSyncEditFields.Destination && hasDestinationConfigChanged(formData)) { + setDestinationConfigToCheck(formData.destinationConfig); + setPendingFormData(formData); + setCheckDuplicateEnabled(true); + return; + } + + await performUpdate(formData); + }; + + const handleConfirmDuplicate = async () => { + if (pendingFormData) { + await performUpdate(pendingFormData); + setPendingFormData(null); + } + setShowDuplicateConfirmation(false); + setCheckDuplicateEnabled(false); + setDestinationConfigToCheck(null); + }; + let Component: ReactNode; switch (fields) { @@ -83,24 +169,35 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => formState: { isSubmitting, isDirty } } = formMethods; + const isLoading = isSubmitting || isCheckingDuplicate; + return ( -
- {Component} -
- - + + - - -
-
+ + + + + ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index eaeab2e66..d6d05a964 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -1,11 +1,14 @@ import { ReactNode } from "react"; import { useFormContext } from "react-hook-form"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { Badge } from "@app/components/v2"; +import { useProject } from "@app/context"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs"; import { AwsParameterStoreDestinationReviewFields, @@ -46,6 +49,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); + const { currentProject } = useProject(); let DestinationFieldsComponent: ReactNode; let AdditionalSyncOptionsFieldsComponent: ReactNode; @@ -63,6 +67,13 @@ export const SecretSyncReviewFields = () => { const destinationName = SECRET_SYNC_MAP[destination].name; + const { hasDuplicate, isChecking } = useDuplicateDestinationCheck({ + destination, + projectId: currentProject?.id || "", + enabled: true, + destinationConfig: watch("destinationConfig") + }); + switch (destination) { case SecretSync.AWSParameterStore: DestinationFieldsComponent = ; @@ -173,9 +184,21 @@ export const SecretSyncReviewFields = () => {
-
+
Destination + {isChecking && Checking...}
+ {hasDuplicate && ( +
+
+

+ + Another secret sync in your organization is already configured with the same + destination. This may lead to conflicts or unexpected behavior. +

+
+
+ )}
{connection.name} {DestinationFieldsComponent} diff --git a/frontend/src/hooks/api/secretSyncs/index.ts b/frontend/src/hooks/api/secretSyncs/index.ts index f49a872a5..0050bad8d 100644 --- a/frontend/src/hooks/api/secretSyncs/index.ts +++ b/frontend/src/hooks/api/secretSyncs/index.ts @@ -2,3 +2,4 @@ export * from "./enums"; export * from "./mutations"; export * from "./queries"; export * from "./types"; +export * from "./useDuplicateDestinationCheck"; diff --git a/frontend/src/hooks/api/secretSyncs/queries.tsx b/frontend/src/hooks/api/secretSyncs/queries.tsx index bb0787a7b..627ff15f3 100644 --- a/frontend/src/hooks/api/secretSyncs/queries.tsx +++ b/frontend/src/hooks/api/secretSyncs/queries.tsx @@ -14,7 +14,15 @@ export const secretSyncKeys = { options: () => [...secretSyncKeys.all, "options"] as const, list: (projectId: string) => [...secretSyncKeys.all, "list", projectId] as const, byId: (destination: SecretSync, syncId: string) => - [...secretSyncKeys.all, destination, "by-id", syncId] as const + [...secretSyncKeys.all, destination, "by-id", syncId] as const, + duplicateCheck: (destination: SecretSync, destinationConfig: unknown, excludeSyncId?: string) => + [ + ...secretSyncKeys.all, + destination, + "duplicate-check", + destinationConfig, + excludeSyncId + ] as const }; export const useSecretSyncOptions = ( @@ -88,3 +96,32 @@ export const useGetSecretSync = ( ...options }); }; + +export const useCheckDuplicateDestination = ( + destination: SecretSync, + destinationConfig: unknown, + projectId: string, + excludeSyncId?: string, + options?: Omit< + UseQueryOptions>, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: secretSyncKeys.duplicateCheck(destination, destinationConfig, excludeSyncId), + queryFn: async () => { + const { data } = await apiRequest.post<{ hasDuplicate: boolean }>( + `/api/v1/secret-syncs/${destination}/check-destination`, + { + destinationConfig, + excludeSyncId, + projectId + } + ); + + return data.hasDuplicate; + }, + enabled: Boolean(destinationConfig) && Object.keys(destinationConfig || {}).length > 0, + ...options + }); +}; diff --git a/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts b/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts new file mode 100644 index 000000000..c570e15a6 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts @@ -0,0 +1,50 @@ +import { useMemo } from "react"; + +import { SecretSync, useCheckDuplicateDestination } from "@app/hooks/api/secretSyncs"; + +type UseDuplicateDestinationCheckProps = { + destination: SecretSync; + projectId: string; + excludeSyncId?: string; + enabled?: boolean; + destinationConfig?: unknown; +}; + +export const useDuplicateDestinationCheck = ({ + destination, + projectId, + excludeSyncId, + enabled = true, + destinationConfig +}: UseDuplicateDestinationCheckProps) => { + const hasValidConfig = useMemo(() => { + if (!destinationConfig || typeof destinationConfig !== "object") return false; + + const values = Object.values(destinationConfig); + return ( + values.length > 0 && + values.some((value) => value !== null && value !== undefined && value !== "") + ); + }, [destinationConfig]); + + const shouldCheck = enabled && hasValidConfig; + + const { + data: hasDuplicate, + isLoading, + error, + refetch + } = useCheckDuplicateDestination(destination, destinationConfig, projectId, excludeSyncId, { + enabled: shouldCheck, + staleTime: 0, + gcTime: 0 + }); + + return { + hasDuplicate: shouldCheck ? Boolean(hasDuplicate) : false, + isChecking: shouldCheck && isLoading, + hasError: Boolean(error), + hasValidConfig, + refetch + }; +};