mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
misc: added audit log and overwrite feature
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user