Skip to content

Commit

Permalink
feat: added resync replication feature
Browse files Browse the repository at this point in the history
  • Loading branch information
akhilmhdh committed May 2, 2024
1 parent 2edaeda commit c671253
Show file tree
Hide file tree
Showing 8 changed files with 213 additions and 7 deletions.
43 changes: 43 additions & 0 deletions backend/src/server/routes/v1/secret-import-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "/",
Expand Down
79 changes: 76 additions & 3 deletions backend/src/services/secret-import/secret-import-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +18,7 @@ import {
TDeleteSecretImportDTO,
TGetSecretImportsDTO,
TGetSecretsFromImportDTO,
TResyncSecretImportReplicationDTO,
TUpdateSecretImportDTO
} from "./secret-import-types";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -319,6 +391,7 @@ export const secretImportServiceFactory = ({
deleteImport,
getImports,
getSecretsFromImports,
resyncSecretImportReplication,
fnSecretsFromImports
};
};
6 changes: 6 additions & 0 deletions backend/src/services/secret-import/secret-import-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/hooks/api/secretImports/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation";
export {
useCreateSecretImport,
useDeleteSecretImport,
useResyncSecretReplication,
useUpdateSecretImport
} from "./mutation";
export {
useGetImportedFoldersByEnv,
useGetImportedSecretsAllEnvs,
Expand Down
20 changes: 19 additions & 1 deletion frontend/src/hooks/api/secretImports/mutation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/hooks/api/secretImports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) =>
Expand All @@ -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
Expand All @@ -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}
Expand Down

0 comments on commit c671253

Please sign in to comment.