Add org option to block duplicate destinations on secret syncs

This commit is contained in:
Carlos Monastyrski
2025-10-24 20:12:35 -03:00
parent 85dfdcc1bc
commit 1722f85e67
18 changed files with 339 additions and 73 deletions

View File

@@ -0,0 +1,27 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn(
TableName.Organization,
"blockDuplicateSecretSyncDestinations"
);
if (hasOrgBlockDuplicateColumn) {
await knex.schema.table(TableName.Organization, (table) => {
table.dropColumn("blockDuplicateSecretSyncDestinations");
});
}
}

View File

@@ -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<typeof OrganizationsSchema>;

View File

@@ -1960,6 +1960,8 @@ export const registerRoutes = async (
secretImportDAL,
permissionService,
appConnectionService,
projectDAL,
orgDAL,
folderDAL,
secretSyncQueue,
projectBotService,

View File

@@ -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({

View File

@@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
scannerProductEnabled: true,
shareSecretsProductEnabled: true,
maxSharedSecretLifetime: true,
maxSharedSecretViewLimit: true
maxSharedSecretViewLimit: true,
blockDuplicateSecretSyncDestinations: true
});

View File

@@ -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;

View File

@@ -90,6 +90,7 @@ export type TUpdateOrgDTO = {
shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null;
blockDuplicateSecretSyncDestinations: boolean;
}>;
} & TOrgPermission;

View File

@@ -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<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">;
orgDAL: Pick<TOrgDALFactory, "findById">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
keyStore: Pick<TKeyStoreFactory, "getItem">;
@@ -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<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 };
}
};
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<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 {
listSecretSyncOptions,
listSecretSyncsByProjectId,

View File

@@ -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 = ({
</FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button onClick={handleNext} colorSchema="secondary">
<Button
onClick={handleNext}
colorSchema="secondary"
isDisabled={isCreateButtonDisabled || (isFinalStep && hasDuplicate)}
>
{isFinalStep ? "Create Sync" : "Next"}
</Button>
{selectedTabIndex > 0 && (

View File

@@ -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<TSecretSyncForm>();
const { currentProject } = useProject();
const { currentOrg } = useOrganization();
let DestinationFieldsComponent: ReactNode;
let AdditionalSyncOptionsFieldsComponent: ReactNode;
@@ -193,18 +194,50 @@ export const SecretSyncReviewFields = () => {
{isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>}
</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="mt-1 mr-2 text-yellow-600" />
<div
className={`mb-2 flex items-start rounded-md border px-3 py-2 ${
currentOrg?.blockDuplicateSecretSyncDestinations
? "border-red-600 bg-red-900/20"
: "border-yellow-600 bg-yellow-900/20"
}`}
>
<div
className={`flex text-sm ${
currentOrg?.blockDuplicateSecretSyncDestinations
? "text-red-100"
: "text-yellow-100"
}`}
>
<FontAwesomeIcon
icon={faWarning}
className={`mt-1 mr-2 ${
currentOrg?.blockDuplicateSecretSyncDestinations
? "text-red-600"
: "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.
{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."}
</p>
{duplicateProjectId && (
<p className="mt-1 text-xs text-yellow-200">
<p
className={`mt-1 text-xs ${
currentOrg?.blockDuplicateSecretSyncDestinations
? "text-red-200"
: "text-yellow-200"
}`}
>
Duplicate found in project ID:{" "}
<code className="rounded-sm bg-yellow-800/50 px-1 py-0.5">
<code
className={`rounded-sm px-1 py-0.5 ${
currentOrg?.blockDuplicateSecretSyncDestinations
? "bg-red-800/50"
: "bg-yellow-800/50"
}`}
>
{duplicateProjectId}
</code>
</p>

View File

@@ -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: () => {

View File

@@ -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 = {

View File

@@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-3 text-xl font-medium">Block Duplicate Secret Sync Destinations</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<div className="w-max">
<Checkbox
id="blockDuplicateSecretSyncDestinations"
isDisabled={!isAllowed || isLoading}
isChecked={currentOrg?.blockDuplicateSecretSyncDestinations ?? false}
onCheckedChange={(state) => handleToggle(state as boolean)}
>
This feature prevents creating secret syncs with destinations that are already in use
by other syncs in your organization.
</Checkbox>
</div>
)}
</OrgPermissionCan>
</div>
);
};

View File

@@ -0,0 +1 @@
export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection";

View File

@@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="mb-6">
<h2 className="text-xl font-medium text-mineshaft-100">Secrets Management</h2>
</div>
<div className="flex items-center justify-between">
<div>
<h3 className="mb-2 text-lg font-medium text-mineshaft-100">
Block Duplicate Secret Sync Destinations
</h3>
<p className="text-sm text-mineshaft-400">
When enabled, this setting prevents the creation of multiple sync configurations
pointing to the same destination.
</p>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Switch
id="blockDuplicateSecretSyncDestinations"
isDisabled={!isAllowed || isLoading}
isChecked={currentOrg?.blockDuplicateSecretSyncDestinations ?? false}
onCheckedChange={(state) => handleToggle(state as boolean)}
/>
)}
</OrgPermissionCan>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgProductSettingsTab } from "./OrgProductSettingsTab";

View File

@@ -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);

View File

@@ -1 +1,3 @@
export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection";
export { OrgProductSettingsTab } from "./OrgProductSettingsTab";
export { OrgTabGroup } from "./OrgTabGroup";