mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4591 from Infisical/ENG-3791
Add warning on same destination secret sync
This commit is contained in:
@@ -53,3 +53,53 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => {
|
|||||||
|
|
||||||
return result;
|
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<string, unknown>).sort();
|
||||||
|
const keys2 = Object.keys(obj2 as Record<string, unknown>).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<string, unknown>)[key], (obj2 as Record<string, unknown>)[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<string, unknown>).filter(([key]) => !skipFields.includes(key))
|
||||||
|
);
|
||||||
|
const filtered2 = Object.fromEntries(
|
||||||
|
Object.entries(obj2 as Record<string, unknown>).filter(([key]) => !skipFields.includes(key))
|
||||||
|
);
|
||||||
|
|
||||||
|
return deepEqual(filtered1, filtered2);
|
||||||
|
};
|
||||||
|
|||||||
@@ -425,4 +425,42 @@ export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TS
|
|||||||
return { secretSync };
|
return { secretSync };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/check-destination",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
tags: [ApiDocsTags.SecretSyncs],
|
||||||
|
body: z.object({
|
||||||
|
destinationConfig: z.unknown(),
|
||||||
|
excludeSyncId: z.string().uuid().optional(),
|
||||||
|
projectId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
hasDuplicate: z.boolean(),
|
||||||
|
duplicateProjectId: z.string().uuid().optional()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { destinationConfig, excludeSyncId, projectId } = req.body;
|
||||||
|
|
||||||
|
const result = await server.services.secretSync.checkDuplicateDestination(
|
||||||
|
{
|
||||||
|
destinationConfig: destinationConfig as Record<string, unknown>,
|
||||||
|
destination,
|
||||||
|
excludeSyncId,
|
||||||
|
projectId
|
||||||
|
},
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||||
import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-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, string> = {
|
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||||
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
|
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
|
||||||
@@ -99,3 +100,104 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
|
|||||||
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
|
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
|
||||||
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular
|
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
|
||||||
|
[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, DestinationDuplicateCheckFn> = {
|
||||||
|
[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 (
|
||||||
|
(newConfig.scope as string) === "group"
|
||||||
|
? existingConfig.groupId !== newConfig.groupId
|
||||||
|
: existingConfig.projectId !== newConfig.projectId
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// If either has wildcard, it conflicts with any targetEnvironment
|
||||||
|
if (
|
||||||
|
!existingTargetEnv ||
|
||||||
|
!newTargetEnv ||
|
||||||
|
wildcardValues.includes(existingTargetEnv) ||
|
||||||
|
wildcardValues.includes(newTargetEnv)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingTargetEnv === newTargetEnv;
|
||||||
|
},
|
||||||
|
[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
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||||
|
import { deepEqualSkipFields } from "@app/lib/fn/object";
|
||||||
import { OrgServiceActor } from "@app/lib/types";
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-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 { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
|
||||||
import {
|
import {
|
||||||
SecretSyncStatus,
|
SecretSyncStatus,
|
||||||
|
TCheckDuplicateDestinationDTO,
|
||||||
TCreateSecretSyncDTO,
|
TCreateSecretSyncDTO,
|
||||||
TDeleteSecretSyncDTO,
|
TDeleteSecretSyncDTO,
|
||||||
TFindSecretSyncByIdDTO,
|
TFindSecretSyncByIdDTO,
|
||||||
@@ -35,7 +37,12 @@ import {
|
|||||||
|
|
||||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||||
import { TSecretSyncDALFactory } from "./secret-sync-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";
|
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
|
||||||
|
|
||||||
type TSecretSyncServiceFactoryDep = {
|
type TSecretSyncServiceFactoryDep = {
|
||||||
@@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({
|
|||||||
return updatedSecretSync as TSecretSync;
|
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<string, unknown>,
|
||||||
|
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 {
|
return {
|
||||||
listSecretSyncOptions,
|
listSecretSyncOptions,
|
||||||
listSecretSyncsByProjectId,
|
listSecretSyncsByProjectId,
|
||||||
@@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({
|
|||||||
deleteSecretSync,
|
deleteSecretSync,
|
||||||
triggerSecretSyncSyncSecretsById,
|
triggerSecretSyncSyncSecretsById,
|
||||||
triggerSecretSyncImportSecretsById,
|
triggerSecretSyncImportSecretsById,
|
||||||
triggerSecretSyncRemoveSecretsById
|
triggerSecretSyncRemoveSecretsById,
|
||||||
|
checkDuplicateDestination
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = {
|
|||||||
removeSecrets: boolean;
|
removeSecrets: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TCheckDuplicateDestinationDTO = {
|
||||||
|
destination: SecretSync;
|
||||||
|
destinationConfig: Record<string, unknown>;
|
||||||
|
excludeSyncId?: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export enum SecretSyncStatus {
|
export enum SecretSyncStatus {
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Running = "running",
|
Running = "running",
|
||||||
@@ -408,3 +415,8 @@ export type TSecretMap = Record<
|
|||||||
secretMetadata?: ResourceMetadataDTO;
|
secretMetadata?: ResourceMetadataDTO;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type DestinationDuplicateCheckFn = (
|
||||||
|
existingConfig: Record<string, unknown>,
|
||||||
|
newConfig: Record<string, unknown>
|
||||||
|
) => boolean;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
duplicateProjectId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DuplicateDestinationConfirmationModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
isLoading,
|
||||||
|
duplicateProjectId
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent className="max-w-lg" title="Duplicate Destination Configuration">
|
||||||
|
<div className="mb-4 text-sm">
|
||||||
|
<p>
|
||||||
|
Another secret sync in your organization is already configured with the same
|
||||||
|
destination. Proceeding may cause conflicts or overwrite existing data.
|
||||||
|
</p>
|
||||||
|
{duplicateProjectId && (
|
||||||
|
<p className="mt-2 text-xs text-mineshaft-400">
|
||||||
|
Duplicate found in project ID:{" "}
|
||||||
|
<code className="rounded bg-mineshaft-600 px-1 py-0.5 text-mineshaft-200">
|
||||||
|
{duplicateProjectId}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2">Are you sure you want to continue?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4">
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
colorSchema="danger"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button colorSchema="secondary" variant="plain" isDisabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { SecretSyncEditFields } from "@app/components/secret-syncs/types";
|
||||||
import { Button, ModalClose } from "@app/components/v2";
|
import { Button, ModalClose } from "@app/components/v2";
|
||||||
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
|
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 { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields";
|
||||||
|
import { DuplicateDestinationConfirmationModal } from "./DuplicateDestinationConfirmationModal";
|
||||||
import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas";
|
import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas";
|
||||||
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
|
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
|
||||||
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
|
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
|
||||||
@@ -23,6 +28,8 @@ type Props = {
|
|||||||
export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => {
|
export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => {
|
||||||
const updateSecretSync = useUpdateSecretSync();
|
const updateSecretSync = useUpdateSecretSync();
|
||||||
const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination];
|
const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination];
|
||||||
|
const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false);
|
||||||
|
const [pendingFormData, setPendingFormData] = useState<TSecretSyncForm | null>(null);
|
||||||
|
|
||||||
const formMethods = useForm<TSecretSyncForm>({
|
const formMethods = useForm<TSecretSyncForm>({
|
||||||
resolver: zodResolver(UpdateSecretSyncFormSchema),
|
resolver: zodResolver(UpdateSecretSyncFormSchema),
|
||||||
@@ -35,11 +42,25 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
|
|||||||
reValidateMode: "onChange"
|
reValidateMode: "onChange"
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => {
|
const [destinationConfigToCheck, setDestinationConfigToCheck] = useState<unknown>(null);
|
||||||
|
const [checkDuplicateEnabled, setCheckDuplicateEnabled] = useState(false);
|
||||||
|
const [storedDuplicateProjectId, setStoredDuplicateProjectId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { data: duplicateData, isLoading: isCheckingDuplicate } = useCheckDuplicateDestination(
|
||||||
|
secretSync.destination,
|
||||||
|
destinationConfigToCheck,
|
||||||
|
secretSync.projectId,
|
||||||
|
secretSync.id,
|
||||||
|
{ enabled: checkDuplicateEnabled && Boolean(destinationConfigToCheck) }
|
||||||
|
);
|
||||||
|
|
||||||
|
const performUpdate = useCallback(
|
||||||
|
async (formData: TSecretSyncForm) => {
|
||||||
try {
|
try {
|
||||||
|
const { environment, connection, ...updateData } = formData;
|
||||||
const updatedSecretSync = await updateSecretSync.mutateAsync({
|
const updatedSecretSync = await updateSecretSync.mutateAsync({
|
||||||
syncId: secretSync.id,
|
syncId: secretSync.id,
|
||||||
...formData,
|
...updateData,
|
||||||
environment: environment?.slug,
|
environment: environment?.slug,
|
||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
projectId: secretSync.projectId
|
projectId: secretSync.projectId
|
||||||
@@ -58,6 +79,77 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
|
|||||||
type: "error"
|
type: "error"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[updateSecretSync, secretSync.id, secretSync.projectId, destinationName, onComplete]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkDuplicateEnabled && !isCheckingDuplicate && destinationConfigToCheck) {
|
||||||
|
if (duplicateData?.hasDuplicate) {
|
||||||
|
setStoredDuplicateProjectId(duplicateData.duplicateProjectId);
|
||||||
|
setShowDuplicateConfirmation(true);
|
||||||
|
} else if (pendingFormData) {
|
||||||
|
performUpdate(pendingFormData);
|
||||||
|
setPendingFormData(null);
|
||||||
|
}
|
||||||
|
setCheckDuplicateEnabled(false);
|
||||||
|
setDestinationConfigToCheck(null);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
checkDuplicateEnabled,
|
||||||
|
isCheckingDuplicate,
|
||||||
|
duplicateData?.hasDuplicate,
|
||||||
|
duplicateData?.duplicateProjectId,
|
||||||
|
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<string, unknown> = {};
|
||||||
|
Object.keys(config as Record<string, unknown>)
|
||||||
|
.sort()
|
||||||
|
.forEach((key) => {
|
||||||
|
normalized[key] = normalizeConfig((config as Record<string, unknown>)[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;
|
let Component: ReactNode;
|
||||||
@@ -83,7 +175,10 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
|
|||||||
formState: { isSubmitting, isDirty }
|
formState: { isSubmitting, isDirty }
|
||||||
} = formMethods;
|
} = formMethods;
|
||||||
|
|
||||||
|
const isLoading = isSubmitting || isCheckingDuplicate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<FormProvider {...formMethods}>{Component}</FormProvider>
|
<FormProvider {...formMethods}>{Component}</FormProvider>
|
||||||
<div className="flex w-full justify-between gap-4 pt-4">
|
<div className="flex w-full justify-between gap-4 pt-4">
|
||||||
@@ -93,14 +188,28 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
|
|||||||
</Button>
|
</Button>
|
||||||
</ModalClose>
|
</ModalClose>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isSubmitting}
|
isLoading={isLoading}
|
||||||
isDisabled={!isDirty || isSubmitting}
|
isDisabled={!isDirty || isLoading}
|
||||||
type="submit"
|
type="submit"
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
>
|
>
|
||||||
Update Sync
|
{isCheckingDuplicate ? "Checking..." : "Update Sync"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<DuplicateDestinationConfirmationModal
|
||||||
|
isOpen={showDuplicateConfirmation}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowDuplicateConfirmation(open);
|
||||||
|
if (!open) {
|
||||||
|
setStoredDuplicateProjectId(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmDuplicate}
|
||||||
|
isLoading={updateSecretSync.isPending}
|
||||||
|
duplicateProjectId={storedDuplicateProjectId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
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 { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||||
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
|
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
|
||||||
import { Badge } from "@app/components/v2";
|
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 { 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 {
|
import {
|
||||||
AwsParameterStoreDestinationReviewFields,
|
AwsParameterStoreDestinationReviewFields,
|
||||||
@@ -46,6 +49,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields";
|
|||||||
|
|
||||||
export const SecretSyncReviewFields = () => {
|
export const SecretSyncReviewFields = () => {
|
||||||
const { watch } = useFormContext<TSecretSyncForm>();
|
const { watch } = useFormContext<TSecretSyncForm>();
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
|
||||||
let DestinationFieldsComponent: ReactNode;
|
let DestinationFieldsComponent: ReactNode;
|
||||||
let AdditionalSyncOptionsFieldsComponent: ReactNode;
|
let AdditionalSyncOptionsFieldsComponent: ReactNode;
|
||||||
@@ -63,6 +67,13 @@ export const SecretSyncReviewFields = () => {
|
|||||||
|
|
||||||
const destinationName = SECRET_SYNC_MAP[destination].name;
|
const destinationName = SECRET_SYNC_MAP[destination].name;
|
||||||
|
|
||||||
|
const { hasDuplicate, duplicateProjectId, isChecking } = useDuplicateDestinationCheck({
|
||||||
|
destination,
|
||||||
|
projectId: currentProject?.id || "",
|
||||||
|
enabled: true,
|
||||||
|
destinationConfig: watch("destinationConfig")
|
||||||
|
});
|
||||||
|
|
||||||
switch (destination) {
|
switch (destination) {
|
||||||
case SecretSync.AWSParameterStore:
|
case SecretSync.AWSParameterStore:
|
||||||
DestinationFieldsComponent = <AwsParameterStoreDestinationReviewFields />;
|
DestinationFieldsComponent = <AwsParameterStoreDestinationReviewFields />;
|
||||||
@@ -173,9 +184,28 @@ export const SecretSyncReviewFields = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="w-full border-b border-mineshaft-600">
|
<div className="flex w-full items-center gap-2 border-b border-mineshaft-600">
|
||||||
<span className="text-sm text-mineshaft-300">Destination</span>
|
<span className="text-sm text-mineshaft-300">Destination</span>
|
||||||
|
{isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{hasDuplicate && (
|
||||||
|
<div className="mb-2 flex items-start rounded-md border border-yellow-600 bg-yellow-900/20 px-3 py-2">
|
||||||
|
<div className="flex text-sm text-yellow-100">
|
||||||
|
<FontAwesomeIcon icon={faWarning} className="mr-2 mt-1 text-yellow-600" />
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Another secret sync in your organization is already configured with the same
|
||||||
|
destination. This may lead to conflicts or unexpected behavior.
|
||||||
|
</p>
|
||||||
|
{duplicateProjectId && (
|
||||||
|
<p className="mt-1 text-xs text-yellow-200">
|
||||||
|
Duplicate found in project ID: <code className="rounded bg-yellow-800/50 px-1 py-0.5">{duplicateProjectId}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
|
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
|
||||||
{DestinationFieldsComponent}
|
{DestinationFieldsComponent}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./enums";
|
|||||||
export * from "./mutations";
|
export * from "./mutations";
|
||||||
export * from "./queries";
|
export * from "./queries";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export * from "./useDuplicateDestinationCheck";
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ export const secretSyncKeys = {
|
|||||||
options: () => [...secretSyncKeys.all, "options"] as const,
|
options: () => [...secretSyncKeys.all, "options"] as const,
|
||||||
list: (projectId: string) => [...secretSyncKeys.all, "list", projectId] as const,
|
list: (projectId: string) => [...secretSyncKeys.all, "list", projectId] as const,
|
||||||
byId: (destination: SecretSync, syncId: string) =>
|
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 = (
|
export const useSecretSyncOptions = (
|
||||||
@@ -88,3 +96,37 @@ export const useGetSecretSync = (
|
|||||||
...options
|
...options
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCheckDuplicateDestination = (
|
||||||
|
destination: SecretSync,
|
||||||
|
destinationConfig: unknown,
|
||||||
|
projectId: string,
|
||||||
|
excludeSyncId?: string,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
{ hasDuplicate: boolean; duplicateProjectId?: string },
|
||||||
|
unknown,
|
||||||
|
{ hasDuplicate: boolean; duplicateProjectId?: string },
|
||||||
|
ReturnType<typeof secretSyncKeys.duplicateCheck>
|
||||||
|
>,
|
||||||
|
"queryKey" | "queryFn"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: secretSyncKeys.duplicateCheck(destination, destinationConfig, excludeSyncId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.post<{
|
||||||
|
hasDuplicate: boolean;
|
||||||
|
duplicateProjectId?: string;
|
||||||
|
}>(`/api/v1/secret-syncs/${destination}/check-destination`, {
|
||||||
|
destinationConfig,
|
||||||
|
excludeSyncId,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(destinationConfig) && Object.keys(destinationConfig || {}).length > 0,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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: duplicateData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
} = useCheckDuplicateDestination(destination, destinationConfig, projectId, excludeSyncId, {
|
||||||
|
enabled: shouldCheck,
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasDuplicate: shouldCheck ? Boolean(duplicateData?.hasDuplicate) : false,
|
||||||
|
duplicateProjectId: duplicateData?.duplicateProjectId,
|
||||||
|
isChecking: shouldCheck && isLoading,
|
||||||
|
hasError: Boolean(error),
|
||||||
|
hasValidConfig,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user