feat: added resync replication feature

This commit is contained in:
Akhil Mohan
2024-05-02 21:11:15 +05:30
committed by =
parent a11f120a83
commit d5229a27b2
8 changed files with 213 additions and 7 deletions

View File

@@ -211,6 +211,49 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
method: "POST",
url: "/:secretImportId/replication-resync",
config: {
rateLimit: secretsLimit
},
schema: {
description: "Resync secret replication of secret imports",
security: [
{
bearerAuth: []
}
],
params: z.object({
secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId)
}),
body: z.object({
workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId),
environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment),
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path)
}),
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { message } = await server.services.secretImport.resyncSecretImportReplication({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.secretImportId,
...req.body,
projectId: req.body.workspaceId
});
return { message };
}
});
server.route({
method: "GET",
url: "/",

View File

@@ -1,5 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability";
import { TableName } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
@@ -17,6 +18,7 @@ import {
TDeleteSecretImportDTO,
TGetSecretImportsDTO,
TGetSecretsFromImportDTO,
TResyncSecretImportReplicationDTO,
TUpdateSecretImportDTO
} from "./secret-import-types";
@@ -109,17 +111,17 @@ export const secretImportServiceFactory = ({
);
});
if (secImport.isReplication) {
if (secImport.isReplication && sourceFolder && membership) {
const importedSecrets = await secretDAL.find({ folderId: sourceFolder?.id });
await secretQueueService.replicateSecrets({
secretPath: secImport.importPath,
projectId,
environmentSlug: importEnv.slug,
pickOnlyImportIds: [secImport.id],
folderId: sourceFolder?.id as string,
folderId: sourceFolder.id,
secrets: importedSecrets.map(({ id, version }) => ({ operation: SecretOperations.Create, version, id })),
// TODO(akhilmhdh): approval based replication this will fail for identity
membershipId: membership?.id as string,
membershipId: membership.id,
environmentId: importEnv.id
});
} else {
@@ -247,6 +249,76 @@ export const secretImportServiceFactory = ({
return secImport;
};
const resyncSecretImportReplication = async ({
environment,
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
path,
id: secretImportDocId
}: TResyncSecretImportReplicationDTO) => {
const { permission, membership } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// check if user has permission to import into destination path
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" });
const [secretImportDoc] = await secretImportDAL.find({
folderId: folder.id,
[`${TableName.SecretImport}.id` as "id"]: secretImportDocId
});
if (!secretImportDoc) throw new BadRequestError({ message: "Failed to find secret import" });
if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" });
// check if user has permission to import from target path
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment: secretImportDoc.importEnv.slug,
secretPath: secretImportDoc.importPath
})
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const sourceFolder = await folderDAL.findBySecretPath(
projectId,
secretImportDoc.importEnv.slug,
secretImportDoc.importPath
);
const importedSecrets = await secretDAL.find({ folderId: sourceFolder?.id });
if (membership && sourceFolder) {
await secretQueueService.replicateSecrets({
secretPath: secretImportDoc.importPath,
projectId,
environmentSlug: secretImportDoc.importEnv.slug,
pickOnlyImportIds: [secretImportDoc.id],
folderId: sourceFolder.id,
secrets: importedSecrets.map(({ id, version }) => ({ operation: SecretOperations.Create, version, id })),
// TODO(akhilmhdh): approval based replication this will fail for identity
membershipId: membership.id,
environmentId: secretImportDoc.importEnv.id
});
}
return { message: "replication started" };
};
const getImports = async ({
path,
environment,
@@ -319,6 +391,7 @@ export const secretImportServiceFactory = ({
deleteImport,
getImports,
getSecretsFromImports,
resyncSecretImportReplication,
fnSecretsFromImports
};
};

View File

@@ -17,6 +17,12 @@ export type TUpdateSecretImportDTO = {
data: Partial<{ environment: string; path: string; position: number }>;
} & TProjectPermission;
export type TResyncSecretImportReplicationDTO = {
environment: string;
path: string;
id: string;
} & TProjectPermission;
export type TDeleteSecretImportDTO = {
environment: string;
path: string;

View File

@@ -1,4 +1,9 @@
export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation";
export {
useCreateSecretImport,
useDeleteSecretImport,
useResyncSecretReplication,
useUpdateSecretImport
} from "./mutation";
export {
useGetImportedFoldersByEnv,
useGetImportedSecretsAllEnvs,

View File

@@ -3,7 +3,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { secretImportKeys } from "./queries";
import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types";
import {
TCreateSecretImportDTO,
TDeleteSecretImportDTO,
TResyncSecretReplicationDTO,
TUpdateSecretImportDTO
} from "./types";
export const useCreateSecretImport = () => {
const queryClient = useQueryClient();
@@ -54,6 +59,19 @@ export const useUpdateSecretImport = () => {
});
};
export const useResyncSecretReplication = () => {
return useMutation<{}, {}, TResyncSecretReplicationDTO>({
mutationFn: async ({ environment, projectId, path, id }) => {
const { data } = await apiRequest.post(`/api/v1/secret-imports/${id}/replication-resync`, {
environment,
path,
workspaceId: projectId
});
return data;
}
});
};
export const useDeleteSecretImport = () => {
const queryClient = useQueryClient();

View File

@@ -76,6 +76,13 @@ export type TUpdateSecretImportDTO = {
}>;
};
export type TResyncSecretReplicationDTO = {
id: string;
projectId: string;
environment: string;
path?: string;
};
export type TDeleteSecretImportDTO = {
id: string;
projectId: string;

View File

@@ -81,7 +81,8 @@ export const CreateSecretImportForm = ({
reset();
createNotification({
type: "success",
text: "Successfully linked"
text: `Successfully linked.${isReplication ? " Kindly refresh the board to see changes." : ""
}`
});
} catch (err) {
console.error(err);

View File

@@ -6,14 +6,18 @@ import {
faFileImport,
faFolder,
faKey,
faRotate,
faUpDown
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer, Tag } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useResyncSecretReplication } from "@app/hooks/api";
type Props = {
onDelete: () => void;
@@ -60,10 +64,12 @@ export const SecretImportItem = ({
secretPath,
environment
}: Props) => {
const { currentWorkspace } = useWorkspace();
const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id
});
const resyncSecretReplication = useResyncSecretReplication();
useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) =>
@@ -88,6 +94,28 @@ export const SecretImportItem = ({
transition
};
const handleResyncSecretReplication = async () => {
if (resyncSecretReplication.isLoading) return;
try {
await resyncSecretReplication.mutateAsync({
id,
environment,
path: secretPath,
projectId: currentWorkspace?.id || ""
});
createNotification({
text: "Kindly refresh the board to see changes.",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to resync replication",
type: "error"
});
}
};
return (
<>
<div
@@ -109,6 +137,31 @@ export const SecretImportItem = ({
isReplication={isReplication}
/>
</div>
<div className="flex items-center space-x-2 px-4 py-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Resync replicated secrets"
>
{(isAllowed) => (
<IconButton
size="md"
colorSchema="primary"
variant="plain"
ariaLabel="expand"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
resyncSecretReplication.isLoading && "animate-spin opacity-100"
)}
isDisabled={!isAllowed}
onClick={handleResyncSecretReplication}
>
<FontAwesomeIcon icon={faRotate} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}