From 1722f85e67586af62ee57968d218ae71b45cff6a Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 24 Oct 2025 20:12:35 -0300 Subject: [PATCH 1/3] Add org option to block duplicate destinations on secret syncs --- ...ock-duplicate-sync-destinations-setting.ts | 27 ++++ backend/src/db/schemas/organizations.ts | 3 +- backend/src/server/routes/index.ts | 2 + .../server/routes/v1/organization-router.ts | 4 + backend/src/services/org/org-schema.ts | 3 +- backend/src/services/org/org-service.ts | 6 +- backend/src/services/org/org-types.ts | 1 + .../secret-sync/secret-sync-service.ts | 140 +++++++++++------- .../forms/CreateSecretSyncForm.tsx | 21 ++- .../SecretSyncReviewFields.tsx | 49 +++++- .../src/hooks/api/organization/queries.tsx | 6 +- frontend/src/hooks/api/organization/types.ts | 2 + ...DuplicateSecretSyncDestinationsSection.tsx | 64 ++++++++ .../index.ts | 1 + .../OrgProductSettingsTab.tsx | 72 +++++++++ .../OrgProductSettingsTab/index.tsx | 1 + .../components/OrgTabGroup/OrgTabGroup.tsx | 8 +- .../SettingsPage/components/index.tsx | 2 + 18 files changed, 339 insertions(+), 73 deletions(-) create mode 100644 backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts create mode 100644 frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx create mode 100644 frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts create mode 100644 frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx create mode 100644 frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx diff --git a/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts b/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts new file mode 100644 index 000000000..7675eb3e1 --- /dev/null +++ b/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn( + TableName.Organization, + "blockDuplicateSecretSyncDestinations" + ); + if (!hasOrgBlockDuplicateColumn) { + await knex.schema.table(TableName.Organization, (table) => { + table.boolean("blockDuplicateSecretSyncDestinations").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn( + TableName.Organization, + "blockDuplicateSecretSyncDestinations" + ); + if (hasOrgBlockDuplicateColumn) { + await knex.schema.table(TableName.Organization, (table) => { + table.dropColumn("blockDuplicateSecretSyncDestinations"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index a1c01151f..3cc7fe858 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -40,7 +40,8 @@ export const OrganizationsSchema = z.object({ googleSsoAuthEnforced: z.boolean().default(false), googleSsoAuthLastUsed: z.date().nullable().optional(), parentOrgId: z.string().uuid().nullable().optional(), - rootOrgId: z.string().uuid().nullable().optional() + rootOrgId: z.string().uuid().nullable().optional(), + blockDuplicateSecretSyncDestinations: z.boolean().default(false) }); export type TOrganizations = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 53cf1f961..44b9a1601 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1960,6 +1960,8 @@ export const registerRoutes = async ( secretImportDAL, permissionService, appConnectionService, + projectDAL, + orgDAL, folderDAL, secretSyncQueue, projectBotService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 76b3eae51..d4b0058f9 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -323,7 +323,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .min(1, "Max Shared Secret view count cannot be lower than 1") .max(1000, "Max Shared Secret view count cannot exceed 1000") .nullable() + .optional(), + blockDuplicateSecretSyncDestinations: z + .boolean() .optional() + .describe("Block duplicate secret sync destinations across the organization") }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 4a3bdb06e..be5c300b5 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ scannerProductEnabled: true, shareSecretsProductEnabled: true, maxSharedSecretLifetime: true, - maxSharedSecretViewLimit: true + maxSharedSecretViewLimit: true, + blockDuplicateSecretSyncDestinations: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 20ed37a06..f0eff547a 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -408,7 +408,8 @@ export const orgServiceFactory = ({ scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -592,7 +593,8 @@ export const orgServiceFactory = ({ scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 48680456c..2587045bb 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -90,6 +90,7 @@ export type TUpdateOrgDTO = { shareSecretsProductEnabled: boolean; maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; + blockDuplicateSecretSyncDestinations: boolean; }>; } & TOrgPermission; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 6a2f49386..b3eb05369 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -15,6 +15,8 @@ import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { deepEqualSkipFields } from "@app/lib/fn/object"; import { OrgServiceActor } from "@app/lib/types"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -50,6 +52,8 @@ type TSecretSyncServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; appConnectionService: Pick; permissionService: Pick; + projectDAL: Pick; + orgDAL: Pick; projectBotService: Pick; folderDAL: Pick; keyStore: Pick; @@ -68,6 +72,8 @@ export const secretSyncServiceFactory = ({ secretImportDAL, permissionService, appConnectionService, + projectDAL, + orgDAL, projectBotService, secretSyncQueue, keyStore, @@ -225,6 +231,61 @@ export const secretSyncServiceFactory = ({ return secretSync as TSecretSync; }; + const checkDuplicateDestination = async ( + { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, + actor: OrgServiceActor + ) => { + const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; + 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 || Object.keys(destinationConfig).length === 0) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + + try { + const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); + + const duplicates = existingSyncs.filter((sync) => { + if (sync.id === excludeSyncId) { + return false; + } + + try { + const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); + if (baseFieldsMatch) { + return DESTINATION_DUPLICATE_CHECK_MAP[destination]( + sync.destinationConfig as Record, + destinationConfig + ); + } + return false; + } catch { + return false; + } + }); + + const hasDuplicate = duplicates.length > 0; + return { + hasDuplicate, + duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined + }; + } catch (error) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + }; + const createSecretSync = async ( { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, actor: OrgServiceActor @@ -271,6 +332,30 @@ export const secretSyncServiceFactory = ({ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` }); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const organization = await orgDAL.findById(project.orgId); + if (organization?.blockDuplicateSecretSyncDestinations) { + const duplicateCheck = await checkDuplicateDestination( + { + destination: params.destination, + destinationConfig: params.destinationConfig, + projectId + }, + actor + ); + if (duplicateCheck.hasDuplicate) { + throw new BadRequestError({ + message: `A secret sync with this destination already exists${ + duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : "" + }.` + }); + } + } + const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination]; // validates permission to connect and app is valid for sync destination @@ -703,61 +788,6 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; - const checkDuplicateDestination = async ( - { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, - actor: OrgServiceActor - ) => { - const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; - 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 || Object.keys(destinationConfig).length === 0) { - return { hasDuplicate: false, duplicateProjectId: undefined }; - } - - try { - const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); - - const duplicates = existingSyncs.filter((sync) => { - if (sync.id === excludeSyncId) { - return false; - } - - try { - const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); - if (baseFieldsMatch) { - return DESTINATION_DUPLICATE_CHECK_MAP[destination]( - sync.destinationConfig as Record, - destinationConfig - ); - } - return false; - } catch { - return false; - } - }); - - const hasDuplicate = duplicates.length > 0; - return { - hasDuplicate, - duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined - }; - } catch (error) { - return { hasDuplicate: false, duplicateProjectId: undefined }; - } - }; - return { listSecretSyncOptions, listSecretSyncsByProjectId, diff --git a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx index 45e0297bd..d10d27803 100644 --- a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx @@ -8,13 +8,14 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Switch } from "@app/components/v2"; -import { useProject } from "@app/context"; +import { useOrganization, useProject } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SecretSync, SecretSyncInitialSyncBehavior, TSecretSync, useCreateSecretSync, + useDuplicateDestinationCheck, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; @@ -48,6 +49,7 @@ export const CreateSecretSyncForm = ({ }: Props) => { const createSecretSync = useCreateSecretSync(); const { currentProject } = useProject(); + const { currentOrg } = useOrganization(); const { name: destinationName } = SECRET_SYNC_MAP[destination]; const [showConfirmation, setShowConfirmation] = useState(false); @@ -106,11 +108,20 @@ export const CreateSecretSyncForm = ({ setSelectedTabIndex((prev) => prev - 1); }; - const { handleSubmit, trigger, control } = formMethods; + const { handleSubmit, trigger, control, watch } = formMethods; + + const { hasDuplicate } = useDuplicateDestinationCheck({ + destination, + projectId: currentProject?.id || "", + enabled: true, + destinationConfig: watch("destinationConfig") + }); const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; + const isCreateButtonDisabled = + isFinalStep && hasDuplicate && currentOrg?.blockDuplicateSecretSyncDestinations; const handleNext = async () => { if (isFinalStep) { @@ -245,7 +256,11 @@ export const CreateSecretSyncForm = ({
- {selectedTabIndex > 0 && ( diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 44d35cd2c..4a796933d 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -6,7 +6,7 @@ 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 { useOrganization, useProject } from "@app/context"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs"; @@ -51,6 +51,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); const { currentProject } = useProject(); + const { currentOrg } = useOrganization(); let DestinationFieldsComponent: ReactNode; let AdditionalSyncOptionsFieldsComponent: ReactNode; @@ -193,18 +194,50 @@ 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. + {currentOrg?.blockDuplicateSecretSyncDestinations + ? "Another secret sync in your organization is already configured with the same destination. This organization has blocking duplicate destinations enabled." + : "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/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index bbf73dd25..4340f9718 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -125,7 +125,8 @@ export const useUpdateOrg = () => { scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -146,7 +147,8 @@ export const useUpdateOrg = () => { scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index e9c36fada..78daa07b5 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -29,6 +29,7 @@ export type Organization = { shareSecretsProductEnabled: boolean; maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; + blockDuplicateSecretSyncDestinations: boolean; }; export type UpdateOrgDTO = { @@ -52,6 +53,7 @@ export type UpdateOrgDTO = { shareSecretsProductEnabled?: boolean; maxSharedSecretViewLimit?: number | null; maxSharedSecretLifetime?: number; + blockDuplicateSecretSyncDestinations?: boolean; }; export type BillingDetails = { diff --git a/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx b/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx new file mode 100644 index 000000000..a2346bd91 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Checkbox } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api/organization/queries"; + +export const BlockDuplicateSecretSyncDestinationsSection = () => { + const { currentOrg } = useOrganization(); + const { mutateAsync: updateOrg } = useUpdateOrg(); + + const [isLoading, setIsLoading] = useState(false); + + const handleToggle = async (state: boolean) => { + setIsLoading(true); + + try { + if (!currentOrg?.id) { + setIsLoading(false); + return; + } + + await updateOrg({ + orgId: currentOrg.id, + blockDuplicateSecretSyncDestinations: state + }); + + createNotification({ + text: `Successfully ${state ? "enabled" : "disabled"} blocking duplicate secret sync destinations for this organization`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update blocking duplicate secret sync destinations setting for this organization", + type: "error" + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Block Duplicate Secret Sync Destinations

+ + {(isAllowed) => ( +
+ handleToggle(state as boolean)} + > + This feature prevents creating secret syncs with destinations that are already in use + by other syncs in your organization. + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts b/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts new file mode 100644 index 000000000..0426a6178 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts @@ -0,0 +1 @@ +export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection"; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx new file mode 100644 index 000000000..5e23a4f97 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Switch } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api/organization/queries"; + +export const OrgProductSettingsTab = () => { + const { currentOrg } = useOrganization(); + const { mutateAsync: updateOrg } = useUpdateOrg(); + + const [isLoading, setIsLoading] = useState(false); + + const handleToggle = async (state: boolean) => { + setIsLoading(true); + + try { + if (!currentOrg?.id) { + setIsLoading(false); + return; + } + + await updateOrg({ + orgId: currentOrg.id, + blockDuplicateSecretSyncDestinations: state + }); + + createNotification({ + text: `Successfully ${state ? "enabled" : "disabled"} blocking duplicate secret sync destinations for this organization`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update blocking duplicate secret sync destinations setting for this organization", + type: "error" + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Secrets Management

+
+
+
+

+ Block Duplicate Secret Sync Destinations +

+

+ When enabled, this setting prevents the creation of multiple sync configurations + pointing to the same destination. +

+
+ + {(isAllowed) => ( + handleToggle(state as boolean)} + /> + )} + +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx new file mode 100644 index 000000000..c2b12e31f --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx @@ -0,0 +1 @@ +export { OrgProductSettingsTab } from "./OrgProductSettingsTab"; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 10eb0f6c0..85d4b259c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -10,6 +10,7 @@ import { ExternalMigrationsTab } from "../ExternalMigrationsTab"; import { KmipTab } from "../KmipTab/OrgKmipTab"; import { OrgEncryptionTab } from "../OrgEncryptionTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; +import { OrgProductSettingsTab } from "../OrgProductSettingsTab"; import { OrgProvisioningTab } from "../OrgProvisioningTab"; import { OrgSecurityTab } from "../OrgSecurityTab"; import { OrgSsoTab } from "../OrgSsoTab"; @@ -63,7 +64,12 @@ export const OrgTabGroup = () => { key: "project-templates", component: ProjectTemplatesTab }, - { name: "KMIP", key: "kmip", component: KmipTab } + { name: "KMIP", key: "kmip", component: KmipTab }, + { + name: "Product Enforcements", + key: "product-enforcements", + component: OrgProductSettingsTab + } ]; const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key); diff --git a/frontend/src/pages/organization/SettingsPage/components/index.tsx b/frontend/src/pages/organization/SettingsPage/components/index.tsx index 648613642..3387ed32b 100644 --- a/frontend/src/pages/organization/SettingsPage/components/index.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/index.tsx @@ -1 +1,3 @@ +export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection"; +export { OrgProductSettingsTab } from "./OrgProductSettingsTab"; export { OrgTabGroup } from "./OrgTabGroup"; From 97a01bdcb40a0ce5f0d826d4330f113d4c7d3f3d Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 27 Oct 2025 18:57:28 -0300 Subject: [PATCH 2/3] Address PR comments --- .../forms/CreateSecretSyncForm.tsx | 6 +- .../DuplicateDestinationConfirmationModal.tsx | 49 ++++++++------ .../secret-syncs/forms/EditSecretSyncForm.tsx | 3 + .../SecretSyncReviewFields.tsx | 2 +- ...DuplicateSecretSyncDestinationsSection.tsx | 64 ------------------- .../index.ts | 1 - .../OrgProductSettingsTab.tsx | 6 +- .../SettingsPage/components/index.tsx | 1 - 8 files changed, 37 insertions(+), 95 deletions(-) delete mode 100644 frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx delete mode 100644 frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts diff --git a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx index d10d27803..ebad86cbd 100644 --- a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx @@ -256,11 +256,7 @@ export const CreateSecretSyncForm = ({
- {selectedTabIndex > 0 && ( diff --git a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx index d80dbc640..59871e880 100644 --- a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx +++ b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx @@ -6,6 +6,7 @@ type Props = { onConfirm: () => void; isLoading?: boolean; duplicateProjectId?: string; + isDisabled?: boolean; }; export const DuplicateDestinationConfirmationModal = ({ @@ -13,7 +14,8 @@ export const DuplicateDestinationConfirmationModal = ({ onOpenChange, onConfirm, isLoading, - duplicateProjectId + duplicateProjectId, + isDisabled }: Props) => { return ( @@ -21,7 +23,12 @@ export const DuplicateDestinationConfirmationModal = ({

Another secret sync in your organization is already configured with the same - destination. Proceeding may cause conflicts or overwrite existing data. + destination.{" "} + + {isDisabled + ? "Your organization does not allow duplicate destination configurations." + : "Proceeding may cause conflicts or overwrite existing data."} +

{duplicateProjectId && (

@@ -31,26 +38,28 @@ export const DuplicateDestinationConfirmationModal = ({

)} -

Are you sure you want to continue?

+ {!isDisabled &&

Are you sure you want to continue?

}
-
- - - - - - -
+ {!isDisabled && ( +
+ + + + + + +
+ )} ); diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 3c0e4ea17..2085afe9c 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { createNotification } from "@app/components/notifications"; import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; import { Button, ModalClose } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync, @@ -30,6 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false); const [pendingFormData, setPendingFormData] = useState(null); + const { currentOrg } = useOrganization(); const formMethods = useForm({ resolver: zodResolver(UpdateSecretSyncFormSchema), @@ -209,6 +211,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => onConfirm={handleConfirmDuplicate} isLoading={updateSecretSync.isPending} duplicateProjectId={storedDuplicateProjectId} + isDisabled={currentOrg?.blockDuplicateSecretSyncDestinations} /> ); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 4a796933d..5c04fb308 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -219,7 +219,7 @@ export const SecretSyncReviewFields = () => {

{currentOrg?.blockDuplicateSecretSyncDestinations - ? "Another secret sync in your organization is already configured with the same destination. This organization has blocking duplicate destinations enabled." + ? "Another secret sync in your organization is already configured with the same destination. Your organization does not allow duplicate destination configurations." : "Another secret sync in your organization is already configured with the same destination. This may lead to conflicts or unexpected behavior."}

{duplicateProjectId && ( diff --git a/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx b/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx deleted file mode 100644 index a2346bd91..000000000 --- a/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/BlockDuplicateSecretSyncDestinationsSection.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from "react"; - -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; -import { Checkbox } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { useUpdateOrg } from "@app/hooks/api/organization/queries"; - -export const BlockDuplicateSecretSyncDestinationsSection = () => { - const { currentOrg } = useOrganization(); - const { mutateAsync: updateOrg } = useUpdateOrg(); - - const [isLoading, setIsLoading] = useState(false); - - const handleToggle = async (state: boolean) => { - setIsLoading(true); - - try { - if (!currentOrg?.id) { - setIsLoading(false); - return; - } - - await updateOrg({ - orgId: currentOrg.id, - blockDuplicateSecretSyncDestinations: state - }); - - createNotification({ - text: `Successfully ${state ? "enabled" : "disabled"} blocking duplicate secret sync destinations for this organization`, - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update blocking duplicate secret sync destinations setting for this organization", - type: "error" - }); - } finally { - setIsLoading(false); - } - }; - - return ( -
-

Block Duplicate Secret Sync Destinations

- - {(isAllowed) => ( -
- handleToggle(state as boolean)} - > - This feature prevents creating secret syncs with destinations that are already in use - by other syncs in your organization. - -
- )} -
-
- ); -}; diff --git a/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts b/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts deleted file mode 100644 index 0426a6178..000000000 --- a/frontend/src/pages/organization/SettingsPage/components/BlockDuplicateSecretSyncDestinationsSection/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection"; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx index 5e23a4f97..e4d935996 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx @@ -49,11 +49,11 @@ export const OrgProductSettingsTab = () => {

- Block Duplicate Secret Sync Destinations + Unique Secret Sync Destination Policy

- When enabled, this setting prevents the creation of multiple sync configurations - pointing to the same destination. + When enabled, ensures each destination can only be used by one secret sync + configuration, preventing potential conflicts or overwrites.

diff --git a/frontend/src/pages/organization/SettingsPage/components/index.tsx b/frontend/src/pages/organization/SettingsPage/components/index.tsx index 3387ed32b..82909f799 100644 --- a/frontend/src/pages/organization/SettingsPage/components/index.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/index.tsx @@ -1,3 +1,2 @@ -export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection"; export { OrgProductSettingsTab } from "./OrgProductSettingsTab"; export { OrgTabGroup } from "./OrgTabGroup"; From d860c5380b16df862020ab73d16d6189f51e3ace Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 27 Oct 2025 21:13:16 -0300 Subject: [PATCH 3/3] Block duplicate destinations on updates if flag is enabled --- .../secret-sync/secret-sync-service.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index b3eb05369..a4e2fc467 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -454,6 +454,33 @@ export const secretSyncServiceFactory = ({ let { folderId } = secretSync; + if (params.destinationConfig) { + const project = await projectDAL.findById(secretSync.projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + const organization = await orgDAL.findById(project.orgId); + + if (organization?.blockDuplicateSecretSyncDestinations) { + const duplicateCheck = await checkDuplicateDestination( + { + destination, + destinationConfig: params.destinationConfig, + projectId: secretSync.projectId, + excludeSyncId: secretSync.id + }, + actor + ); + if (duplicateCheck.hasDuplicate) { + throw new BadRequestError({ + message: `A secret sync with this destination already exists${ + duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : "" + }.` + }); + } + } + } + if (params.connectionId) { const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];