misc: added audit log and overwrite feature

This commit is contained in:
Sheen Capadngan
2024-07-09 21:46:12 +08:00
parent d20ae39f32
commit 079e005f49
8 changed files with 136 additions and 30 deletions

View File

@@ -45,6 +45,7 @@ export enum EventType {
CREATE_SECRETS = "create-secrets", CREATE_SECRETS = "create-secrets",
UPDATE_SECRET = "update-secret", UPDATE_SECRET = "update-secret",
UPDATE_SECRETS = "update-secrets", UPDATE_SECRETS = "update-secrets",
MOVE_SECRETS = "move-secrets",
DELETE_SECRET = "delete-secret", DELETE_SECRET = "delete-secret",
DELETE_SECRETS = "delete-secrets", DELETE_SECRETS = "delete-secrets",
GET_WORKSPACE_KEY = "get-workspace-key", GET_WORKSPACE_KEY = "get-workspace-key",
@@ -228,6 +229,17 @@ interface UpdateSecretBatchEvent {
}; };
} }
interface MoveSecretsEvent {
type: EventType.MOVE_SECRETS;
metadata: {
sourceEnvironment: string;
sourceSecretPath: string;
destinationEnvironment: string;
destinationSecretPath: string;
secretIds: string[];
};
}
interface DeleteSecretEvent { interface DeleteSecretEvent {
type: EventType.DELETE_SECRET; type: EventType.DELETE_SECRET;
metadata: { metadata: {
@@ -1030,6 +1042,7 @@ export type Event =
| CreateSecretBatchEvent | CreateSecretBatchEvent
| UpdateSecretEvent | UpdateSecretEvent
| UpdateSecretBatchEvent | UpdateSecretBatchEvent
| MoveSecretsEvent
| DeleteSecretEvent | DeleteSecretEvent
| DeleteSecretBatchEvent | DeleteSecretBatchEvent
| GetWorkspaceKeyEvent | GetWorkspaceKeyEvent

View File

@@ -1338,27 +1338,45 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), sourceSecretPath: z.string().trim().default("/").transform(removeTrailingSlash),
destinationEnvironment: z.string().trim(), destinationEnvironment: z.string().trim(),
destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash), destinationSecretPath: z.string().trim().default("/").transform(removeTrailingSlash),
secretIds: z.string().array() secretIds: z.string().array(),
shouldOverwrite: z.boolean().default(false)
}), }),
response: { response: {
200: z.union([ 200: z.object({
z.object({ isSourceUpdated: z.boolean(),
secrets: SecretsSchema.omit({ secretBlindIndex: true }).array() isDestinationUpdated: z.boolean()
}), })
z.object({ approval: SecretApprovalRequestsSchema }).describe("When secret protection policy is enabled")
])
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
// TODO: publish audit log const { projectId, isSourceUpdated, isDestinationUpdated } = await server.services.secret.moveSecrets({
return server.services.secret.moveSecrets({
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
...req.body ...req.body
}); });
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.MOVE_SECRETS,
metadata: {
sourceEnvironment: req.body.sourceEnvironment,
sourceSecretPath: req.body.sourceSecretPath,
destinationEnvironment: req.body.destinationEnvironment,
destinationSecretPath: req.body.destinationSecretPath,
secretIds: req.body.secretIds
}
}
});
return {
isSourceUpdated,
isDestinationUpdated
};
} }
}); });

View File

@@ -1705,6 +1705,7 @@ export const secretServiceFactory = ({
destinationSecretPath, destinationSecretPath,
secretIds, secretIds,
projectSlug, projectSlug,
shouldOverwrite,
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
@@ -1793,8 +1794,8 @@ export const secretServiceFactory = ({
}) })
})); }));
let isSourceFolderUpdated = false; let isSourceUpdated = false;
let isDestinationFolderUpdated = false; let isDestinationUpdated = false;
// Moving secrets is a two-step process. // Moving secrets is a two-step process.
await secretDAL.transaction(async (tx) => { await secretDAL.transaction(async (tx) => {
@@ -1831,7 +1832,7 @@ export const secretServiceFactory = ({
const locallyCreatedSecrets = decryptedSourceSecrets const locallyCreatedSecrets = decryptedSourceSecrets
.filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]) .filter(({ secretBlindIndex }) => !destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0])
.map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to create .map((el) => ({ ...el, operation: SecretOperations.Create }));
const locallyUpdatedSecrets = decryptedSourceSecrets const locallyUpdatedSecrets = decryptedSourceSecrets
.filter( .filter(
@@ -1841,16 +1842,25 @@ export const secretServiceFactory = ({
(destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey || (destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey ||
destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== secretValue) destinationSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== secretValue)
) )
.map((el) => ({ ...el, operation: SecretOperations.Update })); // rewrite update ops to create .map((el) => ({ ...el, operation: SecretOperations.Update }));
if (locallyUpdatedSecrets.length > 0 && !shouldOverwrite) {
const existingKeys = locallyUpdatedSecrets.map((s) => s.secretKey);
throw new BadRequestError({
message: `Failed to move secrets. The following secrets already exist in the destination: ${existingKeys.join(
","
)}`
});
}
const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0; const isEmpty = locallyCreatedSecrets.length + locallyUpdatedSecrets.length === 0;
if (isEmpty) { if (isEmpty) {
throw new BadRequestError({ throw new BadRequestError({
message: "No changes were made. Secrets already exist in the destination." message: "Selected secrets already exist in the destination."
}); });
} }
const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy( const destinationFolderPolicy = await secretApprovalPolicyService.getSecretApprovalPolicy(
project.id, project.id,
destinationFolder.environment.slug, destinationFolder.environment.slug,
@@ -1974,7 +1984,7 @@ export const secretServiceFactory = ({
}); });
} }
isDestinationFolderUpdated = true; isDestinationUpdated = true;
} }
// Next step is to delete the secrets from the source folder: // Next step is to delete the secrets from the source folder:
@@ -2042,11 +2052,11 @@ export const secretServiceFactory = ({
tx tx
); );
isSourceFolderUpdated = true; isSourceUpdated = true;
} }
}); });
if (isDestinationFolderUpdated) { if (isDestinationUpdated) {
await snapshotService.performSnapshot(destinationFolder.id); await snapshotService.performSnapshot(destinationFolder.id);
await secretQueueService.syncSecrets({ await secretQueueService.syncSecrets({
projectId: project.id, projectId: project.id,
@@ -2057,7 +2067,7 @@ export const secretServiceFactory = ({
}); });
} }
if (isSourceFolderUpdated) { if (isSourceUpdated) {
await snapshotService.performSnapshot(sourceFolder.id); await snapshotService.performSnapshot(sourceFolder.id);
await secretQueueService.syncSecrets({ await secretQueueService.syncSecrets({
projectId: project.id, projectId: project.id,
@@ -2067,6 +2077,12 @@ export const secretServiceFactory = ({
actor actor
}); });
} }
return {
projectId: project.id,
isSourceUpdated,
isDestinationUpdated
};
}; };
return { return {

View File

@@ -405,4 +405,5 @@ export type TMoveSecretsDTO = {
destinationEnvironment: string; destinationEnvironment: string;
destinationSecretPath: string; destinationSecretPath: string;
secretIds: string[]; secretIds: string[];
shouldOverwrite: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;

View File

@@ -383,22 +383,34 @@ export const useMoveSecrets = ({
} = {}) => { } = {}) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, TMoveSecretsDTO>({ return useMutation<
{
isSourceUpdated: boolean;
isDestinationUpdated: boolean;
},
{},
TMoveSecretsDTO
>({
mutationFn: async ({ mutationFn: async ({
sourceEnvironment, sourceEnvironment,
sourceSecretPath, sourceSecretPath,
projectSlug, projectSlug,
destinationEnvironment, destinationEnvironment,
destinationSecretPath, destinationSecretPath,
secretIds secretIds,
shouldOverwrite
}) => { }) => {
const { data } = await apiRequest.post("/api/v3/secrets/move", { const { data } = await apiRequest.post<{
isSourceUpdated: boolean;
isDestinationUpdated: boolean;
}>("/api/v3/secrets/move", {
sourceEnvironment, sourceEnvironment,
sourceSecretPath, sourceSecretPath,
projectSlug, projectSlug,
destinationEnvironment, destinationEnvironment,
destinationSecretPath, destinationSecretPath,
secretIds secretIds,
shouldOverwrite
}); });
return data; return data;

View File

@@ -185,6 +185,7 @@ export type TMoveSecretsDTO = {
destinationEnvironment: string; destinationEnvironment: string;
destinationSecretPath: string; destinationSecretPath: string;
secretIds: string[]; secretIds: string[];
shouldOverwrite: boolean;
}; };
export type CreateSecretDTO = { export type CreateSecretDTO = {

View File

@@ -239,15 +239,18 @@ export const ActionBar = ({
const handleSecretsMove = async ({ const handleSecretsMove = async ({
destinationEnvironment, destinationEnvironment,
destinationSecretPath destinationSecretPath,
shouldOverwrite
}: { }: {
destinationEnvironment: string; destinationEnvironment: string;
destinationSecretPath: string; destinationSecretPath: string;
shouldOverwrite: boolean;
}) => { }) => {
try { try {
const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id])); const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
await moveSecrets({ const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
projectSlug, projectSlug,
shouldOverwrite,
sourceEnvironment: environment, sourceEnvironment: environment,
sourceSecretPath: secretPath, sourceSecretPath: secretPath,
destinationEnvironment, destinationEnvironment,
@@ -256,10 +259,25 @@ export const ActionBar = ({
secretIds: secretsToMove.map((sec) => sec.id) secretIds: secretsToMove.map((sec) => sec.id)
}); });
let successMessage = "";
if (isDestinationUpdated && isSourceUpdated) {
successMessage = "Successfully moved selected secrets";
} else if (isDestinationUpdated) {
successMessage =
"Successfully created secrets in destination. A secret approval request has been generated for the source.";
} else if (isSourceUpdated) {
successMessage = "A secret approval request has been generated in the destination";
} else {
successMessage =
"A secret approval request has been generated in both the source and the destination.";
}
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully moved selected secrets" text: successMessage
}); });
resetSelectedSecret();
} catch (error) { } catch (error) {
createNotification({ createNotification({
type: "error", type: "error",

View File

@@ -2,7 +2,15 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import {
Button,
Checkbox,
FormControl,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -13,6 +21,7 @@ type Props = {
onMoveApproved: (moveParams: { onMoveApproved: (moveParams: {
destinationEnvironment: string; destinationEnvironment: string;
destinationSecretPath: string; destinationSecretPath: string;
shouldOverwrite: boolean;
}) => void; }) => void;
}; };
@@ -23,7 +32,8 @@ const formSchema = z.object({
.trim() .trim()
.transform((val) => .transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
) ),
shouldOverwrite: z.boolean().default(false)
}); });
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
@@ -44,7 +54,8 @@ export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: P
const handleFormSubmit = (data: TFormSchema) => { const handleFormSubmit = (data: TFormSchema) => {
onMoveApproved({ onMoveApproved({
destinationEnvironment: data.environment, destinationEnvironment: data.environment,
destinationSecretPath: data.secretPath destinationSecretPath: data.secretPath,
shouldOverwrite: data.shouldOverwrite
}); });
handlePopUpToggle("moveSecrets", false); handlePopUpToggle("moveSecrets", false);
@@ -60,7 +71,7 @@ export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: P
> >
<ModalContent <ModalContent
title="Move Secrets" title="Move Secrets"
subTitle="Move selected secrets from current path to selected destination" subTitle="Move secrets from the current path to the selected destination"
> >
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller <Controller
@@ -94,6 +105,22 @@ export const MoveSecretsModal = ({ popUp, handlePopUpToggle, onMoveApproved }: P
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
name="shouldOverwrite"
defaultValue={false}
render={({ field: { onBlur, value, onChange } }) => (
<Checkbox
id="overwrite-checkbox"
className="ml-2"
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
>
Overwrite existing secrets
</Checkbox>
)}
/>
<div className="mt-7 flex items-center"> <div className="mt-7 flex items-center">
<Button <Button
isDisabled={isSubmitting} isDisabled={isSubmitting}