mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 00:27:30 +00:00
Add org option to block duplicate destinations on secret syncs
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,8 @@ export const OrganizationsSchema = z.object({
|
|||||||
googleSsoAuthEnforced: z.boolean().default(false),
|
googleSsoAuthEnforced: z.boolean().default(false),
|
||||||
googleSsoAuthLastUsed: z.date().nullable().optional(),
|
googleSsoAuthLastUsed: z.date().nullable().optional(),
|
||||||
parentOrgId: z.string().uuid().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>;
|
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||||
|
|||||||
@@ -1960,6 +1960,8 @@ export const registerRoutes = async (
|
|||||||
secretImportDAL,
|
secretImportDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
appConnectionService,
|
appConnectionService,
|
||||||
|
projectDAL,
|
||||||
|
orgDAL,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
secretSyncQueue,
|
secretSyncQueue,
|
||||||
projectBotService,
|
projectBotService,
|
||||||
|
|||||||
@@ -323,7 +323,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
.min(1, "Max Shared Secret view count cannot be lower than 1")
|
.min(1, "Max Shared Secret view count cannot be lower than 1")
|
||||||
.max(1000, "Max Shared Secret view count cannot exceed 1000")
|
.max(1000, "Max Shared Secret view count cannot exceed 1000")
|
||||||
.nullable()
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
blockDuplicateSecretSyncDestinations: z
|
||||||
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
|
.describe("Block duplicate secret sync destinations across the organization")
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
|||||||
scannerProductEnabled: true,
|
scannerProductEnabled: true,
|
||||||
shareSecretsProductEnabled: true,
|
shareSecretsProductEnabled: true,
|
||||||
maxSharedSecretLifetime: true,
|
maxSharedSecretLifetime: true,
|
||||||
maxSharedSecretViewLimit: true
|
maxSharedSecretViewLimit: true,
|
||||||
|
blockDuplicateSecretSyncDestinations: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -408,7 +408,8 @@ export const orgServiceFactory = ({
|
|||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled,
|
shareSecretsProductEnabled,
|
||||||
maxSharedSecretLifetime,
|
maxSharedSecretLifetime,
|
||||||
maxSharedSecretViewLimit
|
maxSharedSecretViewLimit,
|
||||||
|
blockDuplicateSecretSyncDestinations
|
||||||
}
|
}
|
||||||
}: TUpdateOrgDTO) => {
|
}: TUpdateOrgDTO) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
@@ -592,7 +593,8 @@ export const orgServiceFactory = ({
|
|||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled,
|
shareSecretsProductEnabled,
|
||||||
maxSharedSecretLifetime,
|
maxSharedSecretLifetime,
|
||||||
maxSharedSecretViewLimit
|
maxSharedSecretViewLimit,
|
||||||
|
blockDuplicateSecretSyncDestinations
|
||||||
});
|
});
|
||||||
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
|
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
|
||||||
return org;
|
return org;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export type TUpdateOrgDTO = {
|
|||||||
shareSecretsProductEnabled: boolean;
|
shareSecretsProductEnabled: boolean;
|
||||||
maxSharedSecretLifetime: number;
|
maxSharedSecretLifetime: number;
|
||||||
maxSharedSecretViewLimit: number | null;
|
maxSharedSecretViewLimit: number | null;
|
||||||
|
blockDuplicateSecretSyncDestinations: boolean;
|
||||||
}>;
|
}>;
|
||||||
} & TOrgPermission;
|
} & TOrgPermission;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
|||||||
import { deepEqualSkipFields } from "@app/lib/fn/object";
|
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 { 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 { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
@@ -50,6 +52,8 @@ type TSecretSyncServiceFactoryDep = {
|
|||||||
secretImportDAL: TSecretImportDALFactory;
|
secretImportDAL: TSecretImportDALFactory;
|
||||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||||
|
orgDAL: Pick<TOrgDALFactory, "findById">;
|
||||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
|
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
|
||||||
keyStore: Pick<TKeyStoreFactory, "getItem">;
|
keyStore: Pick<TKeyStoreFactory, "getItem">;
|
||||||
@@ -68,6 +72,8 @@ export const secretSyncServiceFactory = ({
|
|||||||
secretImportDAL,
|
secretImportDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
appConnectionService,
|
appConnectionService,
|
||||||
|
projectDAL,
|
||||||
|
orgDAL,
|
||||||
projectBotService,
|
projectBotService,
|
||||||
secretSyncQueue,
|
secretSyncQueue,
|
||||||
keyStore,
|
keyStore,
|
||||||
@@ -225,6 +231,61 @@ export const secretSyncServiceFactory = ({
|
|||||||
return secretSync as TSecretSync;
|
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 (
|
const createSecretSync = async (
|
||||||
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
|
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
|
||||||
actor: OrgServiceActor
|
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}"`
|
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];
|
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
|
||||||
|
|
||||||
// validates permission to connect and app is valid for sync destination
|
// validates permission to connect and app is valid for sync destination
|
||||||
@@ -703,61 +788,6 @@ 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,
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { Button, FormControl, Switch } from "@app/components/v2";
|
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 { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
|
||||||
import {
|
import {
|
||||||
SecretSync,
|
SecretSync,
|
||||||
SecretSyncInitialSyncBehavior,
|
SecretSyncInitialSyncBehavior,
|
||||||
TSecretSync,
|
TSecretSync,
|
||||||
useCreateSecretSync,
|
useCreateSecretSync,
|
||||||
|
useDuplicateDestinationCheck,
|
||||||
useSecretSyncOption
|
useSecretSyncOption
|
||||||
} from "@app/hooks/api/secretSyncs";
|
} from "@app/hooks/api/secretSyncs";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export const CreateSecretSyncForm = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const createSecretSync = useCreateSecretSync();
|
const createSecretSync = useCreateSecretSync();
|
||||||
const { currentProject } = useProject();
|
const { currentProject } = useProject();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
const { name: destinationName } = SECRET_SYNC_MAP[destination];
|
const { name: destinationName } = SECRET_SYNC_MAP[destination];
|
||||||
|
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
@@ -106,11 +108,20 @@ export const CreateSecretSyncForm = ({
|
|||||||
setSelectedTabIndex((prev) => prev - 1);
|
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 isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
|
||||||
|
|
||||||
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
|
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
|
||||||
|
const isCreateButtonDisabled =
|
||||||
|
isFinalStep && hasDuplicate && currentOrg?.blockDuplicateSecretSyncDestinations;
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (isFinalStep) {
|
if (isFinalStep) {
|
||||||
@@ -245,7 +256,11 @@ export const CreateSecretSyncForm = ({
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|
||||||
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
|
<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"}
|
{isFinalStep ? "Create Sync" : "Next"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedTabIndex > 0 && (
|
{selectedTabIndex > 0 && (
|
||||||
|
|||||||
+41
-8
@@ -6,7 +6,7 @@ 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 { useOrganization, 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, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs";
|
import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs";
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields";
|
|||||||
export const SecretSyncReviewFields = () => {
|
export const SecretSyncReviewFields = () => {
|
||||||
const { watch } = useFormContext<TSecretSyncForm>();
|
const { watch } = useFormContext<TSecretSyncForm>();
|
||||||
const { currentProject } = useProject();
|
const { currentProject } = useProject();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
|
||||||
let DestinationFieldsComponent: ReactNode;
|
let DestinationFieldsComponent: ReactNode;
|
||||||
let AdditionalSyncOptionsFieldsComponent: ReactNode;
|
let AdditionalSyncOptionsFieldsComponent: ReactNode;
|
||||||
@@ -193,18 +194,50 @@ export const SecretSyncReviewFields = () => {
|
|||||||
{isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>}
|
{isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>}
|
||||||
</div>
|
</div>
|
||||||
{hasDuplicate && (
|
{hasDuplicate && (
|
||||||
<div className="mb-2 flex items-start rounded-md border border-yellow-600 bg-yellow-900/20 px-3 py-2">
|
<div
|
||||||
<div className="flex text-sm text-yellow-100">
|
className={`mb-2 flex items-start rounded-md border px-3 py-2 ${
|
||||||
<FontAwesomeIcon icon={faWarning} className="mt-1 mr-2 text-yellow-600" />
|
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>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
Another secret sync in your organization is already configured with the same
|
{currentOrg?.blockDuplicateSecretSyncDestinations
|
||||||
destination. This may lead to conflicts or unexpected behavior.
|
? "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>
|
</p>
|
||||||
{duplicateProjectId && (
|
{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:{" "}
|
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}
|
{duplicateProjectId}
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export const useUpdateOrg = () => {
|
|||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled,
|
shareSecretsProductEnabled,
|
||||||
maxSharedSecretLifetime,
|
maxSharedSecretLifetime,
|
||||||
maxSharedSecretViewLimit
|
maxSharedSecretViewLimit,
|
||||||
|
blockDuplicateSecretSyncDestinations
|
||||||
}) => {
|
}) => {
|
||||||
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
||||||
name,
|
name,
|
||||||
@@ -146,7 +147,8 @@ export const useUpdateOrg = () => {
|
|||||||
scannerProductEnabled,
|
scannerProductEnabled,
|
||||||
shareSecretsProductEnabled,
|
shareSecretsProductEnabled,
|
||||||
maxSharedSecretLifetime,
|
maxSharedSecretLifetime,
|
||||||
maxSharedSecretViewLimit
|
maxSharedSecretViewLimit,
|
||||||
|
blockDuplicateSecretSyncDestinations
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type Organization = {
|
|||||||
shareSecretsProductEnabled: boolean;
|
shareSecretsProductEnabled: boolean;
|
||||||
maxSharedSecretLifetime: number;
|
maxSharedSecretLifetime: number;
|
||||||
maxSharedSecretViewLimit: number | null;
|
maxSharedSecretViewLimit: number | null;
|
||||||
|
blockDuplicateSecretSyncDestinations: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateOrgDTO = {
|
export type UpdateOrgDTO = {
|
||||||
@@ -52,6 +53,7 @@ export type UpdateOrgDTO = {
|
|||||||
shareSecretsProductEnabled?: boolean;
|
shareSecretsProductEnabled?: boolean;
|
||||||
maxSharedSecretViewLimit?: number | null;
|
maxSharedSecretViewLimit?: number | null;
|
||||||
maxSharedSecretLifetime?: number;
|
maxSharedSecretLifetime?: number;
|
||||||
|
blockDuplicateSecretSyncDestinations?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BillingDetails = {
|
export type BillingDetails = {
|
||||||
|
|||||||
+64
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection";
|
||||||
+72
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { OrgProductSettingsTab } from "./OrgProductSettingsTab";
|
||||||
@@ -10,6 +10,7 @@ import { ExternalMigrationsTab } from "../ExternalMigrationsTab";
|
|||||||
import { KmipTab } from "../KmipTab/OrgKmipTab";
|
import { KmipTab } from "../KmipTab/OrgKmipTab";
|
||||||
import { OrgEncryptionTab } from "../OrgEncryptionTab";
|
import { OrgEncryptionTab } from "../OrgEncryptionTab";
|
||||||
import { OrgGeneralTab } from "../OrgGeneralTab";
|
import { OrgGeneralTab } from "../OrgGeneralTab";
|
||||||
|
import { OrgProductSettingsTab } from "../OrgProductSettingsTab";
|
||||||
import { OrgProvisioningTab } from "../OrgProvisioningTab";
|
import { OrgProvisioningTab } from "../OrgProvisioningTab";
|
||||||
import { OrgSecurityTab } from "../OrgSecurityTab";
|
import { OrgSecurityTab } from "../OrgSecurityTab";
|
||||||
import { OrgSsoTab } from "../OrgSsoTab";
|
import { OrgSsoTab } from "../OrgSsoTab";
|
||||||
@@ -63,7 +64,12 @@ export const OrgTabGroup = () => {
|
|||||||
key: "project-templates",
|
key: "project-templates",
|
||||||
component: ProjectTemplatesTab
|
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);
|
const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key);
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
export { BlockDuplicateSecretSyncDestinationsSection } from "./BlockDuplicateSecretSyncDestinationsSection";
|
||||||
|
export { OrgProductSettingsTab } from "./OrgProductSettingsTab";
|
||||||
export { OrgTabGroup } from "./OrgTabGroup";
|
export { OrgTabGroup } from "./OrgTabGroup";
|
||||||
|
|||||||
Reference in New Issue
Block a user