From 67f20727569aa757373486cf00c8aee24082cfd0 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 29 Sep 2025 17:30:16 -0300 Subject: [PATCH 1/6] Add warning on same destination secret sync --- backend/src/lib/fn/object.ts | 31 ++++ .../secret-sync-endpoints.ts | 35 +++++ .../secret-sync/secret-sync-service.ts | 50 ++++++- .../services/secret-sync/secret-sync-types.ts | 7 + .../DuplicateDestinationConfirmationModal.tsx | 47 ++++++ .../secret-syncs/forms/EditSecretSyncForm.tsx | 139 +++++++++++++++--- .../SecretSyncReviewFields.tsx | 27 +++- frontend/src/hooks/api/secretSyncs/index.ts | 1 + .../src/hooks/api/secretSyncs/queries.tsx | 39 ++++- .../useDuplicateDestinationCheck.ts | 50 +++++++ 10 files changed, 401 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx create mode 100644 frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts 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 + }; +}; From 370569fdeb57912f5082a078258bce94c342addd Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 2 Oct 2025 00:12:48 -0300 Subject: [PATCH 2/6] Address PR comments --- backend/src/lib/fn/object.ts | 19 +++++++ .../secret-sync-endpoints.ts | 2 +- .../services/secret-sync/secret-sync-dal.ts | 16 +++++- .../secret-sync/secret-sync-service.ts | 46 +++++++++++++--- .../services/secret-sync/secret-sync-types.ts | 2 +- .../secret-syncs/forms/EditSecretSyncForm.tsx | 53 ++++++++++--------- 6 files changed, 104 insertions(+), 34 deletions(-) diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 9f44e9da8..6ff7278aa 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -84,3 +84,22 @@ export const deepEqual = (obj1: unknown, obj2: unknown): boolean => { deepEqual((obj1 as Record)[key], (obj2 as Record)[key]) ); }; + +export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: string[] = []): boolean => { + if (skipFields.length === 0) { + return deepEqual(obj1, obj2); + } + + if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 === null || obj2 === null) { + return deepEqual(obj1, obj2); + } + + const filtered1 = Object.fromEntries( + Object.entries(obj1 as Record).filter(([key]) => !skipFields.includes(key)) + ); + const filtered2 = Object.fromEntries( + Object.entries(obj2 as Record).filter(([key]) => !skipFields.includes(key)) + ); + + return deepEqual(filtered1, filtered2); +}; 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 a86c07cf9..d649b3ba5 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 @@ -449,7 +449,7 @@ export const registerSyncSecretsEndpoints = , destination, excludeSyncId, projectId diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index 57c6581ce..64d2c0bdb 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -204,5 +204,19 @@ export const secretSyncDALFactory = ( } }; - return { ...secretSyncOrm, findById, findOne, find, create, updateById }; + const findByDestinationAndOrgId = async (destination: string, orgId: string, tx?: Knex) => { + try { + const response = await (tx || db.replicaNode())(TableName.SecretSync) + .join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.SecretSync}.destination`, destination) + .where(`${TableName.Project}.orgId`, orgId) + .select(selectAllTableCols(TableName.SecretSync)); + + return response; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Destination And Org ID - Secret Sync" }); + } + }; + + return { ...secretSyncOrm, findById, findOne, find, create, updateById, findByDestinationAndOrgId }; }; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 739b7c62c..83751f1b4 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -12,7 +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 { deepEqualSkipFields } 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"; @@ -698,10 +698,46 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; + const getSkipFieldsForDestination = (destination: SecretSync): string[] => { + switch (destination) { + case SecretSync.AWSSecretsManager: + return ["mappingBehavior", "secretName"]; + case SecretSync.OnePass: + return ["valueLabel"]; + case SecretSync.AzureAppConfiguration: + return ["label"]; + case SecretSync.AzureDevOps: + return ["devopsProjectName"]; + case SecretSync.Checkly: + return ["groupName", "accountName"]; + case SecretSync.DigitalOceanAppPlatform: + return ["appName"]; + case SecretSync.GitLab: + return ["projectName", "shouldProtectSecrets", "shouldMaskSecrets", "shouldHideSecrets"]; + case SecretSync.Heroku: + return ["appName"]; + case SecretSync.Netlify: + return ["accountName", "siteName"]; + case SecretSync.Railway: + return ["projectName", "environmentName", "serviceName"]; + case SecretSync.Supabase: + return ["projectName"]; + case SecretSync.TerraformCloud: + return ["variableSetName", "workspaceName"]; + case SecretSync.Vercel: + return ["appName"]; + case SecretSync.Zabbix: + return ["hostName", "macroType"]; + default: + return []; + } + }; + const checkDuplicateDestination = async ( { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, actor: OrgServiceActor ) => { + const skipFields = getSkipFieldsForDestination(destination); const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -716,14 +752,12 @@ export const secretSyncServiceFactory = ({ ProjectPermissionSub.SecretSyncs ); - if (!destinationConfig || typeof destinationConfig !== "object") { + if (!destinationConfig || Object.keys(destinationConfig).length === 0) { return { hasDuplicate: false }; } try { - const existingSyncs = await secretSyncDAL.find({ - destination - }); + const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); const duplicates = existingSyncs.filter((sync) => { if (sync.id === excludeSyncId) { @@ -731,7 +765,7 @@ export const secretSyncServiceFactory = ({ } try { - return deepEqual(sync.destinationConfig, destinationConfig); + return deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); } catch { return false; } diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 92384ff46..e742f4763 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -326,7 +326,7 @@ export type TDeleteSecretSyncDTO = { export type TCheckDuplicateDestinationDTO = { destination: SecretSync; - destinationConfig: unknown; + destinationConfig: Record; excludeSyncId?: string; projectId: string; }; diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 97c8d7acb..2b313b3ff 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, useEffect, useState } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -53,31 +53,34 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => { enabled: checkDuplicateEnabled && Boolean(destinationConfigToCheck) } ); - const performUpdate = async (formData: TSecretSyncForm) => { - try { - const { environment, connection, ...updateData } = formData; - const updatedSecretSync = await updateSecretSync.mutateAsync({ - syncId: secretSync.id, - ...updateData, - environment: environment?.slug, - connectionId: connection.id, - projectId: secretSync.projectId - }); + const performUpdate = useCallback( + async (formData: TSecretSyncForm) => { + try { + const { environment, connection, ...updateData } = formData; + const updatedSecretSync = await updateSecretSync.mutateAsync({ + syncId: secretSync.id, + ...updateData, + environment: environment?.slug, + connectionId: connection.id, + projectId: secretSync.projectId + }); - createNotification({ - text: `Successfully updated ${destinationName} Sync`, - type: "success" - }); - onComplete(updatedSecretSync); - } catch (err: any) { - console.error(err); - createNotification({ - title: `Failed to update ${destinationName} Sync`, - text: err.message, - type: "error" - }); - } - }; + createNotification({ + text: `Successfully updated ${destinationName} Sync`, + type: "success" + }); + onComplete(updatedSecretSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${destinationName} Sync`, + text: err.message, + type: "error" + }); + } + }, + [updateSecretSync, secretSync.id, secretSync.projectId, destinationName, onComplete] + ); useEffect(() => { if (checkDuplicateEnabled && !isCheckingDuplicate && destinationConfigToCheck) { From 76976ab85ac0258ac27f90b93d29de0238559b69 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 2 Oct 2025 18:50:19 -0700 Subject: [PATCH 3/6] improvement: minor ui adjustments --- .../forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index d6d05a964..76a885172 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -189,10 +189,10 @@ export const SecretSyncReviewFields = () => { {isChecking && Checking...}
{hasDuplicate && ( -
-
+
+
+

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

From fb1d5ca2571952367383a6c269eee30fda8ea628 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 3 Oct 2025 01:00:30 -0300 Subject: [PATCH 4/6] Improve Gitlab sync destination check and show projectId on duplicate destination --- .../secret-sync-endpoints.ts | 5 +- .../secret-sync/secret-sync-service.ts | 59 +++++++++++++++++-- .../DuplicateDestinationConfirmationModal.tsx | 12 +++- .../secret-syncs/forms/EditSecretSyncForm.tsx | 17 ++++-- .../SecretSyncReviewFields.tsx | 17 ++++-- .../src/hooks/api/secretSyncs/queries.tsx | 25 ++++---- .../useDuplicateDestinationCheck.ts | 5 +- 7 files changed, 112 insertions(+), 28 deletions(-) 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 d649b3ba5..9da2f4b69 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 @@ -440,7 +440,10 @@ export const registerSyncSecretsEndpoints = , + newConfig: Record + ): boolean => { + switch (destination) { + case SecretSync.GitLab: { + const existingTargetEnv = existingConfig.targetEnvironment as string | undefined; + const newTargetEnv = newConfig.targetEnvironment as string | undefined; + + // If either has wildcard '*', it conflicts with any targetEnvironment + if (existingTargetEnv === "*" || newTargetEnv === "*") { + return true; + } + + return ( + existingTargetEnv === newTargetEnv && + ((newConfig.scope as string) === "group" + ? existingConfig.groupId === newConfig.groupId + : existingConfig.projectId === newConfig.projectId) + ); + } + default: + // For other sync types, no special handling needed + return true; + } + }; + const checkDuplicateDestination = async ( { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, actor: OrgServiceActor @@ -753,7 +790,7 @@ export const secretSyncServiceFactory = ({ ); if (!destinationConfig || Object.keys(destinationConfig).length === 0) { - return { hasDuplicate: false }; + return { hasDuplicate: false, duplicateProjectId: undefined }; } try { @@ -765,15 +802,27 @@ export const secretSyncServiceFactory = ({ } try { - return deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); + const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); + if (baseFieldsMatch) { + return handleSpecialCaseDuplicateCheck( + destination, + sync.destinationConfig as Record, + destinationConfig + ); + } + return false; } catch { return false; } }); - return { hasDuplicate: duplicates.length > 0 }; + const hasDuplicate = duplicates.length > 0; + return { + hasDuplicate, + duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined + }; } catch (error) { - return { hasDuplicate: false }; + return { hasDuplicate: false, duplicateProjectId: undefined }; } }; diff --git a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx index 45ec2c1be..82ab06038 100644 --- a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx +++ b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx @@ -5,13 +5,15 @@ type Props = { onOpenChange: (isOpen: boolean) => void; onConfirm: () => void; isLoading?: boolean; + duplicateProjectId?: string; }; export const DuplicateDestinationConfirmationModal = ({ isOpen, onOpenChange, onConfirm, - isLoading + isLoading, + duplicateProjectId }: Props) => { return ( @@ -21,6 +23,14 @@ export const DuplicateDestinationConfirmationModal = ({ Another secret sync in your organization is already configured with the same destination. Proceeding may cause conflicts or overwrite existing data.

+ {duplicateProjectId && ( +

+ Duplicate found in project ID:{" "} + + {duplicateProjectId} + +

+ )}

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 2b313b3ff..3c0e4ea17 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -44,8 +44,9 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => const [destinationConfigToCheck, setDestinationConfigToCheck] = useState(null); const [checkDuplicateEnabled, setCheckDuplicateEnabled] = useState(false); + const [storedDuplicateProjectId, setStoredDuplicateProjectId] = useState(); - const { data: hasDuplicate, isLoading: isCheckingDuplicate } = useCheckDuplicateDestination( + const { data: duplicateData, isLoading: isCheckingDuplicate } = useCheckDuplicateDestination( secretSync.destination, destinationConfigToCheck, secretSync.projectId, @@ -84,7 +85,8 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => useEffect(() => { if (checkDuplicateEnabled && !isCheckingDuplicate && destinationConfigToCheck) { - if (hasDuplicate) { + if (duplicateData?.hasDuplicate) { + setStoredDuplicateProjectId(duplicateData.duplicateProjectId); setShowDuplicateConfirmation(true); } else if (pendingFormData) { performUpdate(pendingFormData); @@ -96,7 +98,8 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => }, [ checkDuplicateEnabled, isCheckingDuplicate, - hasDuplicate, + duplicateData?.hasDuplicate, + duplicateData?.duplicateProjectId, destinationConfigToCheck, pendingFormData, performUpdate @@ -197,9 +200,15 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => { + setShowDuplicateConfirmation(open); + if (!open) { + setStoredDuplicateProjectId(undefined); + } + }} onConfirm={handleConfirmDuplicate} isLoading={updateSecretSync.isPending} + duplicateProjectId={storedDuplicateProjectId} /> ); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 76a885172..84801b000 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -67,7 +67,7 @@ export const SecretSyncReviewFields = () => { const destinationName = SECRET_SYNC_MAP[destination].name; - const { hasDuplicate, isChecking } = useDuplicateDestinationCheck({ + const { hasDuplicate, duplicateProjectId, isChecking } = useDuplicateDestinationCheck({ destination, projectId: currentProject?.id || "", enabled: true, @@ -192,10 +192,17 @@ export const SecretSyncReviewFields = () => {
-

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

+
+

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

+ {duplicateProjectId && ( +

+ Duplicate found in project ID: {duplicateProjectId} +

+ )} +
)} diff --git a/frontend/src/hooks/api/secretSyncs/queries.tsx b/frontend/src/hooks/api/secretSyncs/queries.tsx index 627ff15f3..250d02ff8 100644 --- a/frontend/src/hooks/api/secretSyncs/queries.tsx +++ b/frontend/src/hooks/api/secretSyncs/queries.tsx @@ -103,23 +103,28 @@ export const useCheckDuplicateDestination = ( projectId: string, excludeSyncId?: string, options?: Omit< - UseQueryOptions>, + UseQueryOptions< + { hasDuplicate: boolean; duplicateProjectId?: string }, + unknown, + { hasDuplicate: boolean; duplicateProjectId?: string }, + ReturnType + >, "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 - } - ); + const { data } = await apiRequest.post<{ + hasDuplicate: boolean; + duplicateProjectId?: string; + }>(`/api/v1/secret-syncs/${destination}/check-destination`, { + destinationConfig, + excludeSyncId, + projectId + }); - return data.hasDuplicate; + return data; }, 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 index c570e15a6..97985fba9 100644 --- a/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts +++ b/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts @@ -30,7 +30,7 @@ export const useDuplicateDestinationCheck = ({ const shouldCheck = enabled && hasValidConfig; const { - data: hasDuplicate, + data: duplicateData, isLoading, error, refetch @@ -41,7 +41,8 @@ export const useDuplicateDestinationCheck = ({ }); return { - hasDuplicate: shouldCheck ? Boolean(hasDuplicate) : false, + hasDuplicate: shouldCheck ? Boolean(duplicateData?.hasDuplicate) : false, + duplicateProjectId: duplicateData?.duplicateProjectId, isChecking: shouldCheck && isLoading, hasError: Boolean(error), hasValidConfig, From 0292788c2c5d47c8daff0b17644861da76d20a26 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 6 Oct 2025 22:39:53 -0300 Subject: [PATCH 5/6] Improve types and maps for secret sync warning logic --- .../services/secret-sync/secret-sync-maps.ts | 99 +++++++++++++++++++ .../secret-sync/secret-sync-service.ts | 84 ++-------------- .../services/secret-sync/secret-sync-types.ts | 5 + 3 files changed, 112 insertions(+), 76 deletions(-) diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 04e91051d..c0784a62d 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -1,5 +1,6 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; +import { DestinationDuplicateCheckFn } from "@app/services/secret-sync/secret-sync-types"; export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AWSParameterStore]: "AWS Parameter Store", @@ -99,3 +100,101 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular }; + +export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { + [SecretSync.AWSParameterStore]: [], + [SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"], + [SecretSync.GitHub]: [], + [SecretSync.GCPSecretManager]: [], + [SecretSync.AzureKeyVault]: [], + [SecretSync.AzureAppConfiguration]: ["label"], + [SecretSync.AzureDevOps]: ["devopsProjectName"], + [SecretSync.Databricks]: [], + [SecretSync.Humanitec]: [], + [SecretSync.TerraformCloud]: ["variableSetName", "workspaceName"], + [SecretSync.Camunda]: [], + [SecretSync.Vercel]: ["appName"], + [SecretSync.Windmill]: [], + [SecretSync.HCVault]: [], + [SecretSync.TeamCity]: [], + [SecretSync.OCIVault]: [], + [SecretSync.OnePass]: ["valueLabel"], + [SecretSync.Heroku]: ["appName"], + [SecretSync.Render]: [], + [SecretSync.Flyio]: [], + [SecretSync.GitLab]: [ + "projectName", + "shouldProtectSecrets", + "shouldMaskSecrets", + "shouldHideSecrets", + "targetEnvironment", + "groupName", + "groupId", + "projectId" + ], + [SecretSync.CloudflarePages]: [], + [SecretSync.CloudflareWorkers]: [], + [SecretSync.Supabase]: ["projectName"], + [SecretSync.Zabbix]: ["hostName", "macroType"], + [SecretSync.Railway]: ["projectName", "environmentName", "serviceName"], + [SecretSync.Checkly]: ["groupName", "accountName"], + [SecretSync.DigitalOceanAppPlatform]: ["appName"], + [SecretSync.Netlify]: ["accountName", "siteName"], + [SecretSync.Bitbucket]: [] +}; + +const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; + +export const DESTINATION_DUPLICATE_CHECK_MAP: Record = { + [SecretSync.AWSParameterStore]: defaultDuplicateCheck, + [SecretSync.AWSSecretsManager]: defaultDuplicateCheck, + [SecretSync.GitHub]: defaultDuplicateCheck, + [SecretSync.GCPSecretManager]: defaultDuplicateCheck, + [SecretSync.AzureKeyVault]: defaultDuplicateCheck, + [SecretSync.AzureAppConfiguration]: defaultDuplicateCheck, + [SecretSync.AzureDevOps]: defaultDuplicateCheck, + [SecretSync.Databricks]: defaultDuplicateCheck, + [SecretSync.Humanitec]: defaultDuplicateCheck, + [SecretSync.TerraformCloud]: defaultDuplicateCheck, + [SecretSync.Camunda]: defaultDuplicateCheck, + [SecretSync.Vercel]: defaultDuplicateCheck, + [SecretSync.Windmill]: defaultDuplicateCheck, + [SecretSync.HCVault]: defaultDuplicateCheck, + [SecretSync.TeamCity]: defaultDuplicateCheck, + [SecretSync.OCIVault]: defaultDuplicateCheck, + [SecretSync.OnePass]: defaultDuplicateCheck, + [SecretSync.Heroku]: defaultDuplicateCheck, + [SecretSync.Render]: defaultDuplicateCheck, + [SecretSync.Flyio]: defaultDuplicateCheck, + [SecretSync.GitLab]: (existingConfig, newConfig) => { + const existingTargetEnv = existingConfig.targetEnvironment as string | undefined; + const newTargetEnv = newConfig.targetEnvironment as string | undefined; + + const wildcardValues = ["*", ""]; + // If either has wildcard, it conflicts with any targetEnvironment + if ( + !existingTargetEnv || + !newTargetEnv || + wildcardValues.includes(existingTargetEnv) || + wildcardValues.includes(newTargetEnv) + ) { + return true; + } + + return ( + existingTargetEnv === newTargetEnv && + ((newConfig.scope as string) === "group" + ? existingConfig.groupId === newConfig.groupId + : existingConfig.projectId === newConfig.projectId) + ); + }, + [SecretSync.CloudflarePages]: defaultDuplicateCheck, + [SecretSync.CloudflareWorkers]: defaultDuplicateCheck, + [SecretSync.Supabase]: defaultDuplicateCheck, + [SecretSync.Zabbix]: defaultDuplicateCheck, + [SecretSync.Railway]: defaultDuplicateCheck, + [SecretSync.Checkly]: defaultDuplicateCheck, + [SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck, + [SecretSync.Netlify]: defaultDuplicateCheck, + [SecretSync.Bitbucket]: defaultDuplicateCheck +}; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index c4b44c0a5..6a2f49386 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -37,7 +37,12 @@ import { import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretSyncDALFactory } from "./secret-sync-dal"; -import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps"; +import { + DESTINATION_DUPLICATE_CHECK_MAP, + SECRET_SYNC_CONNECTION_MAP, + SECRET_SYNC_NAME_MAP, + SECRET_SYNC_SKIP_FIELDS_MAP +} from "./secret-sync-maps"; import { TSecretSyncQueueFactory } from "./secret-sync-queue"; type TSecretSyncServiceFactoryDep = { @@ -698,83 +703,11 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; - const getSkipFieldsForDestination = (destination: SecretSync): string[] => { - switch (destination) { - case SecretSync.AWSSecretsManager: - return ["mappingBehavior", "secretName"]; - case SecretSync.OnePass: - return ["valueLabel"]; - case SecretSync.AzureAppConfiguration: - return ["label"]; - case SecretSync.AzureDevOps: - return ["devopsProjectName"]; - case SecretSync.Checkly: - return ["groupName", "accountName"]; - case SecretSync.DigitalOceanAppPlatform: - return ["appName"]; - case SecretSync.GitLab: - return [ - "projectName", - "shouldProtectSecrets", - "shouldMaskSecrets", - "shouldHideSecrets", - "targetEnvironment", - "groupName", - "groupId", - "projectId" - ]; - case SecretSync.Heroku: - return ["appName"]; - case SecretSync.Netlify: - return ["accountName", "siteName"]; - case SecretSync.Railway: - return ["projectName", "environmentName", "serviceName"]; - case SecretSync.Supabase: - return ["projectName"]; - case SecretSync.TerraformCloud: - return ["variableSetName", "workspaceName"]; - case SecretSync.Vercel: - return ["appName"]; - case SecretSync.Zabbix: - return ["hostName", "macroType"]; - default: - return []; - } - }; - - const handleSpecialCaseDuplicateCheck = ( - destination: SecretSync, - existingConfig: Record, - newConfig: Record - ): boolean => { - switch (destination) { - case SecretSync.GitLab: { - const existingTargetEnv = existingConfig.targetEnvironment as string | undefined; - const newTargetEnv = newConfig.targetEnvironment as string | undefined; - - // If either has wildcard '*', it conflicts with any targetEnvironment - if (existingTargetEnv === "*" || newTargetEnv === "*") { - return true; - } - - return ( - existingTargetEnv === newTargetEnv && - ((newConfig.scope as string) === "group" - ? existingConfig.groupId === newConfig.groupId - : existingConfig.projectId === newConfig.projectId) - ); - } - default: - // For other sync types, no special handling needed - return true; - } - }; - const checkDuplicateDestination = async ( { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, actor: OrgServiceActor ) => { - const skipFields = getSkipFieldsForDestination(destination); + const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; const { permission } = await permissionService.getProjectPermission({ actor: actor.type, actorId: actor.id, @@ -804,8 +737,7 @@ export const secretSyncServiceFactory = ({ try { const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); if (baseFieldsMatch) { - return handleSpecialCaseDuplicateCheck( - destination, + return DESTINATION_DUPLICATE_CHECK_MAP[destination]( sync.destinationConfig as Record, destinationConfig ); diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index e742f4763..478c44c92 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -415,3 +415,8 @@ export type TSecretMap = Record< secretMetadata?: ResourceMetadataDTO; } >; + +export type DestinationDuplicateCheckFn = ( + existingConfig: Record, + newConfig: Record +) => boolean; From eb1ae216a61140c44517b47da7b24ccf4ee21501 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 6 Oct 2025 19:37:00 -0700 Subject: [PATCH 6/6] fix: correct gitlab check order for conflict --- .../src/services/secret-sync/secret-sync-maps.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index c0784a62d..1fbc66cca 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -171,6 +171,14 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record